A somewhat usable and tested version.
authorRadek Czajka <radekczajka@nowoczesnapolska.org.pl>
Fri, 29 Aug 2014 15:06:53 +0000 (17:06 +0200)
committerRadek Czajka <radekczajka@nowoczesnapolska.org.pl>
Fri, 29 Aug 2014 15:06:53 +0000 (17:06 +0200)
40 files changed:
.gitignore
runtests.py
setup.py
ssify/__init__.py
ssify/cache.py
ssify/decorators.py
ssify/exceptions.py
ssify/management/commands/ssify_nginx_conf.py
ssify/middleware.py
ssify/middleware_debug.py
ssify/serializers.py
ssify/store.py
ssify/templatetags/ssify.py
ssify/variables.py
tests/templates/tests/main.html [deleted file]
tests/templates/tests/number_zero.html [deleted file]
tests/templates/tests/quote.html [deleted file]
tests/templates/tests/random_quote.html [deleted file]
tests/templates/tests_args/args.html [new file with mode: 0644]
tests/templates/tests_args/include_args.html [new file with mode: 0644]
tests/templates/tests_basic/basic_include.html [new file with mode: 0644]
tests/templates/tests_basic/main.html [new file with mode: 0644]
tests/templates/tests_basic/number_zero.html [new file with mode: 0644]
tests/templates/tests_basic/quote.html [new file with mode: 0644]
tests/templates/tests_basic/random_quote.html [new file with mode: 0644]
tests/templates/tests_csrf/csrf_token.html [new file with mode: 0644]
tests/templates/tests_locale/bad_language.html [new file with mode: 0644]
tests/templates/tests_locale/include_language_with_lang.html [new file with mode: 0644]
tests/templates/tests_locale/include_language_without_lang.html [new file with mode: 0644]
tests/templatetags/test_tags.py
tests/tests.py [deleted file]
tests/tests/__init__.py [new file with mode: 0644]
tests/tests/test_args.py [new file with mode: 0644]
tests/tests/test_basic.py [new file with mode: 0644]
tests/tests/test_csrf.py [new file with mode: 0644]
tests/tests/test_locale.py [new file with mode: 0644]
tests/tests_utils.py [new file with mode: 0644]
tests/urls.py
tests/views.py
tox.ini [new file with mode: 0644]

index ea2e8e1..03034ac 100644 (file)
@@ -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
index 8d27ecf..3d066dc 100644 (file)
@@ -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'))
index d549279..b9e6332 100755 (executable)
--- 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",
     ]
index 591c70a..51322bc 100644 (file)
@@ -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)
index 7f42145..2fa846c 100644 (file)
@@ -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
 
index 9b67c25..80dc2f5 100644 (file)
@@ -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__)
index 8acc55c..d9e5319 100644 (file)
@@ -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 "<unknown>"
+
+
+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])
index 48f53dc..d177811 100644 (file)
@@ -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
 
index 4351083..a1f8444 100644 (file)
@@ -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(
index 2bd4fa0..5eb1cbd 100644 (file)
@@ -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):
index a96356c..8a44c7b 100644 (file)
@@ -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
 
index 499a991..ec40bb0 100644 (file)
@@ -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
 
 
index f538fe6..06be39e 100644 (file)
@@ -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)
index 9ccc3f3..e969450 100644 (file)
@@ -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("<!--#echo var='%s' encoding='none'-->" % 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 "<!--#set var='%s' value='%s'-->" % (
         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/main.html b/tests/templates/tests/main.html
deleted file mode 100644 (file)
index fb5eff3..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-{% load ssify %}
-
-{% ssi_include 'random_quote' %}
\ No newline at end of file
diff --git a/tests/templates/tests/number_zero.html b/tests/templates/tests/number_zero.html
deleted file mode 100644 (file)
index a01b50f..0000000
+++ /dev/null
@@ -1 +0,0 @@
-{% load test_tags %}{% random_number 1 %}
\ No newline at end of file
diff --git a/tests/templates/tests/quote.html b/tests/templates/tests/quote.html
deleted file mode 100644 (file)
index 6ddabd3..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{% load test_tags %}
-{% quote_len_odd number as odd %}
-{{ quote }}
-Line {{ number }} of {% number_of_quotes %}
-{{ odd.if }}Odd number of characters.
-{{ odd.else }}Even number of characters.
-{{ odd.endif }}
diff --git a/tests/templates/tests/random_quote.html b/tests/templates/tests/random_quote.html
deleted file mode 100644 (file)
index 66611aa..0000000
+++ /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 (file)
index 0000000..253fcde
--- /dev/null
@@ -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 (file)
index 0000000..f045266
--- /dev/null
@@ -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 (file)
index 0000000..adb399b
--- /dev/null
@@ -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_basic/main.html b/tests/templates/tests_basic/main.html
new file mode 100644 (file)
index 0000000..fb5eff3
--- /dev/null
@@ -0,0 +1,3 @@
+{% load ssify %}
+
+{% ssi_include 'random_quote' %}
\ No newline at end of file
diff --git a/tests/templates/tests_basic/number_zero.html b/tests/templates/tests_basic/number_zero.html
new file mode 100644 (file)
index 0000000..996b721
--- /dev/null
@@ -0,0 +1,2 @@
+{% load ssify test_tags %}
+{% random_number 1 %}
diff --git a/tests/templates/tests_basic/quote.html b/tests/templates/tests_basic/quote.html
new file mode 100644 (file)
index 0000000..6ddabd3
--- /dev/null
@@ -0,0 +1,7 @@
+{% load test_tags %}
+{% quote_len_odd number as odd %}
+{{ quote }}
+Line {{ number }} of {% number_of_quotes %}
+{{ odd.if }}Odd number of characters.
+{{ odd.else }}Even number of characters.
+{{ odd.endif }}
diff --git a/tests/templates/tests_basic/random_quote.html b/tests/templates/tests_basic/random_quote.html
new file mode 100644 (file)
index 0000000..9fdac51
--- /dev/null
@@ -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 (file)
index 0000000..9b0845f
--- /dev/null
@@ -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 (file)
index 0000000..5428496
--- /dev/null
@@ -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 (file)
index 0000000..7ddf3de
--- /dev/null
@@ -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 (file)
index 0000000..320be2f
--- /dev/null
@@ -0,0 +1,3 @@
+{% load ssify %}
+
+{% ssi_include 'language_without_lang' %}
\ No newline at end of file
index 8c9dfea..d0a43c1 100644 (file)
@@ -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 (file)
index 82751c7..0000000
+++ /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,
-            "<!--#set var='ve023a08d2c2075118e25b5f4339438dc' value='0'-->"
-            "<!--#echo var='ve023a08d2c2075118e25b5f4339438dc' "
-            "encoding='none'-->"
-        )
-
-    def test_single_quote(self):
-        self.assertEqual(
-            self.client.get('/quote/3').content.strip(),
-            """Explicit is better than implicit.
-Line 3 of <!--#echo var='va50d914691ecf9b421c680d93ba1263e' encoding='none'-->
-<!--#if expr='${vddc386e120ab274a980ab67384391a1a}'-->Odd number of characters.
-<!--#else-->Even number of characters.
-<!--#endif-->"""
-        )
-
-    def test_random_quote(self):
-        self.assertEqual(
-            self.client.get('/').content.strip(),
-            "<!--#set var='vda0df841702ea993b36d101460264364' value='4'-->"
-            "<!--#set var='va50d914691ecf9b421c680d93ba1263e' value='22'-->"
-            "<!--#set var='vafe010f2e683908fee32c48d01bb2650' value=''-->"
-            "\n\n<!--#include file='/random_quote'-->"
-        )
-
-        # Do it again, this time from cache.
-        self.assertEqual(
-            self.client.get('/').content.strip(),
-            "<!--#set var='vda0df841702ea993b36d101460264364' value='4'-->"
-            "<!--#set var='va50d914691ecf9b421c680d93ba1263e' value='22'-->"
-            "<!--#set var='vafe010f2e683908fee32c48d01bb2650' value=''-->"
-            "\n\n<!--#include file='/random_quote'-->"
-        )
-        self.assertEqual(
-            self.client.get('/random_quote').content.strip(),
-            "<!--#include "
-            "file='/quote/${vda0df841702ea993b36d101460264364}'-->"
-        )
-
-    @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 (file)
index 0000000..0fb0ebb
--- /dev/null
@@ -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 (file)
index 0000000..a45f47c
--- /dev/null
@@ -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"<!--#set var='vff80027f1d552d08d46c8b603948d85c' value='2'-->",
+                 b"<!--#set var='veeb5ec4364971b409c48e36bd1428d03' value='1'-->",
+                 b"<!--#set var='v05a1f9ec205c5aa84197f6b326c518a2' value='0'-->",
+                 b"<!--#echo var='v05a1f9ec205c5aa84197f6b326c518a2' encoding='none'-->",
+                 ])
+            )
+
+    def test_args_included(self):
+        self.assertEqual(
+            self.client.get('/args/3').content.strip(),
+            b"<!--#echo var='v05a1f9ec205c5aa84197f6b326c518a2' encoding='none'-->"
+            )
+
+    def test_include_args(self):
+        self.assertEqual(
+            sorted(split_ssi(self.client.get('/include_args').content)),
+            sorted([b"<!--#set var='vf6aba0780227af845107c046f336cc8a' value='3'-->",
+                 b"<!--#set var='vff80027f1d552d08d46c8b603948d85c' value='2'-->",
+                 b"<!--#set var='veeb5ec4364971b409c48e36bd1428d03' value='1'-->",
+                 b"<!--#set var='v05a1f9ec205c5aa84197f6b326c518a2' value='0'-->",
+                 b"<!--#include file='/args/${vf6aba0780227af845107c046f336cc8a}'-->",
+                 ]),
+            )
+
+        # Test a second time, this time from cache.
+        self.assertEqual(
+            sorted(split_ssi(self.client.get('/include_args').content)),
+            sorted([b"<!--#set var='vf6aba0780227af845107c046f336cc8a' value='3'-->",
+                 b"<!--#set var='vff80027f1d552d08d46c8b603948d85c' value='2'-->",
+                 b"<!--#set var='veeb5ec4364971b409c48e36bd1428d03' value='1'-->",
+                 b"<!--#set var='v05a1f9ec205c5aa84197f6b326c518a2' value='0'-->",
+                 b"<!--#include file='/args/${vf6aba0780227af845107c046f336cc8a}'-->",
+                 ]),
+            )
+
+    @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 (file)
index 0000000..0c32349
--- /dev/null
@@ -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"<!--#set var='ve023a08d2c2075118e25b5f4339438dc' value='0'-->\n"
+            b"<!--#echo var='ve023a08d2c2075118e25b5f4339438dc' "
+            b"encoding='none'-->"
+        )
+
+    def test_basic_include(self):
+        self.assertEqual(
+            self.client.get('/basic_include').content.strip(),
+            b"<!--#include file='/language/pl'-->"
+        )
+
+    def test_single_quote(self):
+        self.assertEqual(
+            self.client.get('/quote/3').content.strip(),
+            b"""Explicit is better than implicit.
+Line 3 of <!--#echo var='va50d914691ecf9b421c680d93ba1263e' encoding='none'-->
+<!--#if expr='${vddc386e120ab274a980ab67384391a1a}'-->Odd number of characters.
+<!--#else-->Even number of characters.
+<!--#endif-->"""
+        )
+
+    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"<!--#set var='va50d914691ecf9b421c680d93ba1263e' value='22'-->",
+                 b"<!--#set var='v3e7f638af74c9f420b6d2c5fe4dda51d' value='4'-->",
+                 b"<!--#set var='vafe010f2e683908fee32c48d01bb2650' value=''-->",
+                 b"<!--#include file='/random_quote'-->"])
+        )
+
+        # Do it again, this time from cache.
+        self.assertEqual(
+            sorted(split_ssi(self.client.get('/').content)),
+            sorted([b"<!--#set var='va50d914691ecf9b421c680d93ba1263e' value='22'-->",
+                 b"<!--#set var='v3e7f638af74c9f420b6d2c5fe4dda51d' value='4'-->",
+                 b"<!--#set var='vafe010f2e683908fee32c48d01bb2650' value=''-->",
+                 b"<!--#include file='/random_quote'-->"])
+        )
+        self.assertEqual(
+            self.client.get('/random_quote').content.strip(),
+            b"<!--#include "
+            b"file='/quote/${v3e7f638af74c9f420b6d2c5fe4dda51d}'-->"
+        )
+
+    @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 (file)
index 0000000..62173ce
--- /dev/null
@@ -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(),
+            ("<!--#set var='vd07f6920655622adc90dd591c545bb2a' value='%s'-->\n\n"
+            "<input type='hidden' name='csrfmiddlewaretoken' value='"
+            "<!--#echo var='vd07f6920655622adc90dd591c545bb2a' "
+            "encoding='none'-->' />" % 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 (file)
index 0000000..7cfacc0
--- /dev/null
@@ -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 (file)
index 0000000..e838bc4
--- /dev/null
@@ -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*(?=<!--#)')
+
+def split_ssi(ssi_text):
+    """re.split won't split on empty sequence, so we need that."""
+    ssi_text = ssi_text.strip()
+    start = 0
+    for match in re.finditer(splitter, ssi_text):
+        yield ssi_text[start:match.start()]
+        start = match.end()
+    yield ssi_text[start:]
index 53c0ef7..6b92b09 100644 (file)
@@ -1,3 +1,9 @@
+# -*- 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.urls import patterns, url
 from django.views.generic import TemplateView
 
@@ -5,12 +11,46 @@ from django.views.generic import TemplateView
 urlpatterns = patterns(
     'tests.views',
 
+    # tests.basic
     url(r'^$',
-        TemplateView.as_view(template_name='tests/main.html')
+        TemplateView.as_view(template_name='tests_basic/main.html')
         ),
     url(r'^number_zero$',
-        TemplateView.as_view(template_name='tests/number_zero.html')
+        TemplateView.as_view(template_name='tests_basic/number_zero.html')
+        ),
+    url(r'^basic_include$',
+        TemplateView.as_view(template_name='tests_basic/basic_include.html')
         ),
     url(r'^random_quote$', 'random_quote', name='random_quote'),
     url(r'^quote/(?P<number>.+)$', 'quote', name='quote'),
+
+    url(r'^quote_undeclared/(?P<number>.+)$', 'quote_undeclared'),
+    url(r'^quote_overdeclared/(?P<number>.+)$', 'quote_overdeclared'),
+
+    # tests.args
+    url(r'^include_args$',
+        TemplateView.as_view(template_name='tests_args/include_args.html'),
+        ),
+    url(r'^args$',
+        TemplateView.as_view(template_name='tests_args/args.html'),
+        {'limit': 3}
+        ),
+    url(r'^args/(?P<limit>\d+)$', 'args', name='args'),
+
+    # tests.csrf
+    url(r'^csrf$',
+        TemplateView.as_view(template_name='tests_csrf/csrf_token.html'),
+        ),
+    url(r'^csrf_check$', 'csrf_check'),
+
+    # tests.locale
+    url(r'^include_language_with_lang$',
+        TemplateView.as_view(template_name='tests_locale/include_language_with_lang.html')
+        ),
+    url(r'^include_language_without_lang$',
+        TemplateView.as_view(template_name='tests_locale/include_language_without_lang.html')
+        ),
+    url(r'^language/(?P<lang>.+)$', 'language_with_lang', name='language_with_lang'),
+    url(r'^language$', 'language_without_lang', name='language_without_lang'),
+    url(r'^bad_language$', 'language_with_lang', name='bad_language_with_lang'),
 )
index 4c6fa90..9544937 100644 (file)
@@ -1,22 +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.http import HttpResponse
 from django.shortcuts import render
+from django.utils import translation
 from ssify import ssi_included, ssi_expect, SsiVariable as V
 
 
 @ssi_included(use_lang=False, get_ssi_vars=lambda number: [
     ('test_tags.number_of_quotes',),
-    ('test_tags.quote_len_odd', (ssi_expect(number, int),))
+    ('test_tags.quote_len_odd', [ssi_expect(number, int)])
 ])
 def quote(request, number):
     number = int(number)
-    return render(request, 'tests/quote.html', {
+    return render(request, 'tests_basic/quote.html', {
+        'number': number,
+        'quote': QUOTES[number]
+    })
+
+
+@ssi_included(use_lang=False)
+def quote_undeclared(request, number):
+    number = int(number)
+    return render(request, 'tests_basic/quote.html', {
         'number': number,
         'quote': QUOTES[number]
     })
 
 
+@ssi_included(use_lang=False, get_ssi_vars=lambda number: [
+    ('test_tags.number_of_quotes',),
+    ('test_tags.quote_len_odd', [ssi_expect(number, int)]),
+    ('test_tags.quote_len_odd', [V('nonexistent')]),
+])
+def quote_overdeclared(request, number):
+    number = int(number)
+    return render(request, 'tests_basic/quote.html', {
+        'number': number,
+        'quote': QUOTES[number]
+    })
+
+
+
 @ssi_included(use_lang=False, get_ssi_vars=lambda: (
     lambda number: [number] + quote.get_ssi_vars(number))(
-        number=V('test_tags.random_number', [V('test_tags.number_of_quotes')])
+        number=V('test_tags.random_number', (), {'limit': V('test_tags.number_of_quotes')})
     ))
 def random_quote(request):
     """
@@ -28,17 +59,53 @@ def random_quote(request):
     then sets a third saying if the length of the selected quote is odd.
 
     """
-    return render(request, 'tests/random_quote.html')
+    return render(request, 'tests_basic/random_quote.html')
+
+
+def language(request):
+    assert request.LANGUAGE_CODE == translation.get_language()
+    return HttpResponse(request.LANGUAGE_CODE)
+
+language_without_lang = ssi_included(use_lang=False)(language)
+language_with_lang = ssi_included(language)
+
+
+
+
+@ssi_included(use_lang=False, get_ssi_vars=lambda limit: (
+    ('test_tags.random_number', [], {'limit': ssi_expect(limit, int)}),
+    ('test_tags.random_number', [V(
+        'test_tags.random_number', [], {'limit': ssi_expect(limit, int)}
+    )]),
+    ('test_tags.random_number', [], {'limit': V(
+        'test_tags.random_number', [V(
+            'test_tags.random_number', [], {'limit': ssi_expect(limit, int)}
+        )]
+    )}),
+    ))
+def args(request, limit):
+    return render(request, 'tests_args/args.html', {'limit': int(limit)})
+
+
+def csrf_check(request):
+    return HttpResponse(request.POST['test'])
 
 
 # Nothing interesting here.
 def _quotes():
     import sys
-    import cStringIO
     stdout_backup = sys.stdout
-    sys.stdout = cStringIO.StringIO()
+    try:
+        # Python 2
+        from cStringIO import StringIO
+        sys.stdout = StringIO()
+    except ImportError:
+        # Python 3
+        from io import BytesIO, TextIOWrapper
+        sys.stdout = TextIOWrapper(BytesIO(), sys.stdout.encoding)
     import this
-    this_string = sys.stdout.getvalue()
+    sys.stdout.seek(0)
+    this_string = sys.stdout.read()
     sys.stdout.close()
     sys.stdout = stdout_backup
     return this_string.split('\n')
diff --git a/tox.ini b/tox.ini
new file mode 100644 (file)
index 0000000..acef65a
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,126 @@
+# This file is part of django-ssify, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See README.md for more information.
+#
+[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,
+    dd-py27, dd-py32, dd-py33, dd-py34,
+    stats
+
+[testenv]
+indexserver=http://py.mdrn.pl
+commands=coverage run --source=ssify --append --branch runtests.py
+deps=coverage
+
+[testenv:clear]
+commands=coverage erase
+
+[testenv:stats]
+commands=coverage html
+
+[base]
+
+[testenv:d14-py26]
+basepython=python2.6
+deps=
+    Django>=1.4,<1.5
+    {[testenv]deps}
+
+[testenv:d15-py26]
+basepython=python2.6
+deps=
+    Django>=1.5,<1.6
+    {[testenv]deps}
+
+[testenv:d15-py27]
+basepython=python2.7
+deps=
+    Django>=1.5,<1.6
+    {[testenv]deps}
+
+[testenv:d15-py32]
+basepython=python3.2
+deps=
+    Django>=1.5,<1.6
+    {[testenv]deps}
+
+[testenv:d15-py33]
+basepython=python3.3
+deps=
+    Django>=1.5,<1.6
+    {[testenv]deps}
+
+[testenv:d16-py26]
+basepython=python2.6
+deps=
+    Django>=1.6,<1.7
+    {[testenv]deps}
+
+[testenv:d16-py27]
+basepython=python2.7
+deps=
+    Django>=1.6,<1.7
+    {[testenv]deps}
+
+[testenv:d16-py32]
+basepython=python3.2
+deps=
+    Django>=1.6,<1.7
+    {[testenv]deps}
+
+[testenv:d16-py33]
+basepython=python3.3
+deps=
+    Django>=1.6,<1.7
+    {[testenv]deps}
+
+[testenv:d17-py27]
+basepython=python2.7
+deps=
+    https://www.djangoproject.com/download/1.7c3/tarball/
+    {[testenv]deps}
+
+[testenv:d17-py32]
+basepython=python3.2
+deps=
+    https://www.djangoproject.com/download/1.7c3/tarball/
+    {[testenv]deps}
+
+[testenv:d17-py33]
+basepython=python3.3
+deps=
+    https://www.djangoproject.com/download/1.7c3/tarball/
+    {[testenv]deps}
+
+[testenv:d17-py34]
+basepython=python3.4
+deps=
+    https://www.djangoproject.com/download/1.7c3/tarball/
+    {[testenv]deps}
+
+[testenv:dd-py27]
+basepython=python2.7
+deps=
+    https://github.com/django/django/zipball/master
+    {[testenv]deps}
+
+[testenv:dd-py32]
+basepython=python3.2
+deps=
+    https://github.com/django/django/zipball/master
+    {[testenv]deps}
+
+[testenv:dd-py33]
+basepython=python3.3
+deps=
+    https://github.com/django/django/zipball/master
+    {[testenv]deps}
+
+[testenv:dd-py34]
+basepython=python3.4
+deps=
+    https://github.com/django/django/zipball/master
+    {[testenv]deps}