changeset 54:9dfbc2a40b71

Formatter changes: - Refactored `PageResolver` with something that makes more sense. - Fixed some bugs with advanced include/meta scenarios. - Added more tests.
author Ludovic Chabant <ludovic@chabant.com>
date Sat, 02 Feb 2013 20:16:54 -0800
parents c4e999f55ba9
children 494f3c4660ed
files tests/__init__.py tests/test_page.py wikked/formatter.py wikked/page.py
diffstat 4 files changed, 224 insertions(+), 56 deletions(-) [+]
line wrap: on
line diff
--- a/tests/__init__.py	Thu Jan 31 22:41:07 2013 -0800
+++ b/tests/__init__.py	Sat Feb 02 20:16:54 2013 -0800
@@ -31,3 +31,24 @@
 
     def getParameters(self):
         return MockWikiParameters()
+
+
+def format_link(title, url, missing=False, mod=None):
+    res = '<a class=\"wiki-link'
+    if missing:
+        res += ' missing'
+    res += '\" data-wiki-url=\"' + url + '\"'
+    if mod:
+        res += ' data-wiki-mod=\"' + mod + '\"'
+    res += '>' + title + '</a>'
+    return res
+
+def format_include(url, args=None, mod=None):
+    res = '<div class=\"wiki-include\" data-wiki-url=\"' + url + '\"'
+    if mod:
+        res += ' data-wiki-mod=\"' + mod + '\"'
+    res += '>'
+    if args:
+        res += args
+    res += "</div>\n"
+    return res
--- a/tests/test_page.py	Thu Jan 31 22:41:07 2013 -0800
+++ b/tests/test_page.py	Sat Feb 02 20:16:54 2013 -0800
@@ -1,4 +1,4 @@
-from tests import WikkedTest
+from tests import WikkedTest, format_link, format_include
 from mock import MockFileSystem
 from wikked.page import Page
 
@@ -60,7 +60,11 @@
         page = Page(self.wiki, 'test_links')
         self.assertEqual('test_links', page.url)
         self.assertEqual("Follow a link to the [[Sandbox]]. Or to [[this page|Other Sandbox]].", page.raw_text)
-        self.assertEqual("Follow a link to the <a class=\"wiki-link\" data-wiki-url=\"sandbox\">Sandbox</a>. Or to <a class=\"wiki-link missing\" data-wiki-url=\"other-sandbox\">this page</a>.", page.formatted_text)
+        self.assertEqual(
+                "Follow a link to the %s. Or to %s." % (
+                    format_link('Sandbox', 'sandbox'),
+                    format_link('this page', 'other-sandbox', True)),
+                page.formatted_text)
         self.assertEqual(set(['sandbox', 'other-sandbox']), set(page.local_links))
 
     def testPageRelativeOutLinks(self):
@@ -88,7 +92,9 @@
             })
         foo = Page(self.wiki, 'foo')
         self.assertEqual(['trans-desc'], foo.local_includes)
-        self.assertEqual("A test page.\n<div class=\"wiki-include\" data-wiki-url=\"trans-desc\"></div>\n", foo.formatted_text)
+        self.assertEqual(
+                "A test page.\n%s" % format_include('trans-desc'),
+                foo.formatted_text)
         self.assertEqual("A test page.\nBLAH\n\n", foo.text)
 
     def testPageIncludeWithMeta(self):
@@ -100,8 +106,12 @@
         self.assertEqual(['trans-desc'], foo.local_includes)
         self.assertEqual([], foo.local_links)
         self.assertEqual({'include': 'trans-desc'}, foo.local_meta)
-        self.assertEqual("A test page.\n<div class=\"wiki-include\" data-wiki-url=\"trans-desc\"></div>\n", foo.formatted_text)
-        self.assertEqual("A test page.\nBLAH: <a class=\"wiki-link missing\" data-wiki-url=\"somewhere\">Somewhere</a>\n\n\n\n", foo.text)
+        self.assertEqual(
+                "A test page.\n%s" % format_include('trans-desc'),
+                foo.formatted_text)
+        self.assertEqual(
+                "A test page.\nBLAH: %s\n\n\n\n" % format_link('Somewhere', 'somewhere', True),
+                foo.text)
         self.assertEqual(['trans-desc'], foo.all_includes)
         self.assertEqual(['somewhere'], foo.all_links)
         self.assertEqual({'bar': '42', 'given': 'hope', 'include': 'trans-desc'}, foo.all_meta)
@@ -112,6 +122,56 @@
             'Greeting.txt': "Hello {{name}}, would you like a {{what}}?"
             })
         foo = Page(self.wiki, 'foo')
-        self.assertEqual("A test page.\n<div class=\"wiki-include\" data-wiki-url=\"greeting\">name=Dave|what=drink</div>\n", foo.formatted_text)
+        self.assertEqual(
+            "A test page.\n%s" % format_include('greeting', 'name=Dave|what=drink'),
+            foo.formatted_text)
         self.assertEqual("A test page.\nHello Dave, would you like a drink?\n", foo.text)
 
+    def testGivenOnlyInclude(self):
+        self.wiki = self._getWikiFromStructure({
+            'Base.txt': "The base page.\n{{include: Template 1}}",
+            'Template 1.txt': "TEMPLATE!\n{{+include: Template 2}}",
+            'Template 2.txt': "MORE TEMPLATE!"
+            })
+        tpl1 = Page(self.wiki, 'template-1')
+        self.assertEqual(
+                "TEMPLATE!\n%s" % format_include('template-2', mod='+'),
+                tpl1.formatted_text)
+        self.assertEqual("TEMPLATE!\n\n", tpl1.text)
+        base = Page(self.wiki, 'base')
+        self.assertEqual("The base page.\nTEMPLATE!\nMORE TEMPLATE!\n\n", base.text)
+
+    def testDoublePageIncludeWithMeta(self):
+        return
+        self.wiki = self._getWikiFromStructure({
+            'Base.txt': "The base page.\n{{include: Template 1}}",
+            'Wrong.txt': "{{include: Template 2}}",
+            'Template 1.txt': "{{foo: bar}}\n{{+category: blah}}\n{{+include: Template 2}}\n{{__secret1: ssh}}",
+            'Template 2.txt': "{{+category: yolo}}",
+            'Query 1.txt': "{{query: category=yolo}}",
+            'Query 2.txt': "{{query: category=blah}}"
+            })
+        base = Page(self.wiki, 'base')
+        self.assertEqual({
+            'foo': 'bar', 
+            'category': ['blah', 'yolo']
+            }, base.all_meta)
+        tpl1 = Page(self.wiki, 'template-1')
+        self.assertEqual({
+            'foo': 'bar',
+            '+category': 'blah',
+            '+include': 'template-2',
+            '__secret': 'ssh'
+            }, tpl1.all_meta)
+        self.assertEqual(
+                "\n\n%s\n\n" % format_include('template-2'),
+                tpl1.text)
+        q1 = Page(self.wiki, 'query-1')
+        self.assertEqual(
+                "<ul>\n<li>%s</li>\n<li>%s</li>\n</ul>" % (format_link('Base', 'base'), format_link('Wrong', 'wrong')),
+                q1.text)
+        q2 = Page(self.wiki, 'query-2')
+        self.assertEqual(
+                "<ul>\n<li>%s</li>\n</ul>" % format_link('Base', 'base'),
+                q2.text)
+
--- a/wikked/formatter.py	Thu Jan 31 22:41:07 2013 -0800
+++ b/wikked/formatter.py	Sat Feb 02 20:16:54 2013 -0800
@@ -1,19 +1,43 @@
 import os
 import os.path
 import re
+import types
+
+
+def get_meta_name_and_modifiers(name):
+    """ Strips a meta name from any leading modifiers like `__` or `+`
+        and returns both as a tuple. If no modifier was found, the
+        second tuple value is `None`.
+    """
+    clean_name = name
+    modifiers = None
+    if name[:2] == '__':
+        modifiers = '__'
+        clean_name = name[3:]
+    elif name[0] == '+':
+        modifiers = '+'
+        clean_name = name[1:]
+    return (clean_name, modifiers)
 
 
 class FormatterNotFound(Exception):
+    """ An exception raised when not formatter is found for the
+        current page.
+    """
     pass
 
 
 class CircularIncludeError(Exception):
+    """ An exception raised when a circular include is found
+        while rendering a page.
+    """
     def __init__(self, message, url_trail):
         Exception.__init__(self, message)
         self.url_trail = url_trail
 
 
 class BaseContext(object):
+    """ Base context for formatting pages. """
     def __init__(self, url, slugify=None):
         self.url = url
         self.slugify = slugify
@@ -38,6 +62,7 @@
 
 
 class FormattingContext(BaseContext):
+    """ Context for formatting pages. """
     def __init__(self, url, ext, slugify):
         BaseContext.__init__(self, url, slugify)
         self.ext = ext
@@ -47,6 +72,10 @@
 
 
 class PageFormatter(object):
+    """ An object responsible for formatting a page, i.e. rendering
+        "stable" content (everything except queries run on the fly,
+        like `include` or `query`).
+    """
     def __init__(self, wiki):
         self.wiki = wiki
 
@@ -78,16 +107,18 @@
             if meta_value is not None and len(meta_value) > 0:
                 if meta_name not in ctx.meta:
                     ctx.meta[meta_name] = meta_value
-                elif ctx.meta[meta_name] is list:
+                elif isinstance(ctx.meta[meta_name], types.StringTypes):
+                    ctx.meta[meta_name] = [ctx.meta[meta_name], meta_value]
+                else:
                     ctx.meta[meta_name].append(meta_value)
-                else:
-                    ctx.meta[meta_name] = [ctx.meta[meta_name], meta_value]
             else:
                 ctx.meta[meta_name] = True
-            if meta_name == 'include':
-                return self._processInclude(ctx, meta_value)
-            elif meta_name == 'query':
-                return self._processQuery(ctx, meta_value)
+
+            clean_meta_name, meta_modifier = get_meta_name_and_modifiers(meta_name)
+            if clean_meta_name == 'include':
+                return self._processInclude(ctx, meta_modifier, meta_value)
+            elif clean_meta_name == 'query':
+                return self._processQuery(ctx, meta_modifier, meta_value)
             return ''
 
         text = re.sub(r'^\{\{((__|\+)?[a-zA-Z][a-zA-Z0-9_\-]+):\s*(.*)\}\}\s*$', repl, text, flags=re.MULTILINE)
@@ -110,7 +141,7 @@
 
         return text
 
-    def _processInclude(self, ctx, value):
+    def _processInclude(self, ctx, modifier, value):
         pipe_idx = value.find('|')
         if pipe_idx < 0:
             included_url = ctx.getAbsoluteUrl(value)
@@ -120,11 +151,18 @@
             parameters = value[pipe_idx + 1:]
         ctx.included_pages.append(included_url)
         # Includes are run on the fly.
-        return '<div class="wiki-include" data-wiki-url="%s">%s</div>\n' % (included_url, parameters)
+        url_attr = ' data-wiki-url="%s"' % included_url
+        mod_attr = ''
+        if modifier:
+            mod_attr = ' data-wiki-mod="%s"' % modifier
+        return '<div class="wiki-include"%s%s>%s</div>\n' % (url_attr, mod_attr, parameters)
 
-    def _processQuery(self, ctx, query):
+    def _processQuery(self, ctx, modifier, query):
         # Queries are run on the fly.
-        return '<div class="wiki-query">%s</div>\n' % query
+        mod_attr = ''
+        if modifier:
+            mod_attr = ' data-wiki-mod="%s"' % modifier
+        return '<div class="wiki-query"%s>%s</div>\n' % (mod_attr, query)
 
     def _formatWikiLink(self, ctx, display, url):
         abs_url = ctx.getAbsoluteUrl(url)
@@ -136,25 +174,44 @@
         return '<a class="%s" data-wiki-url="%s">%s</a>' % (css_class, abs_url, display)
 
 
-class ResolvingContext(object):
-    def __init__(self):
+class ResolveContext(object):
+    """ The context for resolving page queries. """
+    def __init__(self, root_url=None):
         self.url_trail = set()
+        if root_url:
+            self.url_trail.add(root_url)
+
+    def shouldRunMeta(self, modifier):
+        if modifier is None:
+            return True
+        if modifier == '__':
+            return len(self.url_trail) <= 1
+        if modifier == '+':
+            return len(self.url_trail) > 1
+        raise ValueError("Unknown modifier: " + modifier)
+
+
+class ResolveOutput(object):
+    """ The results of a resolve operation. """
+    def __init__(self, page=None):
+        self.text = ''
         self.meta = {}
         self.out_links = []
         self.included_pages = []
+        if page:
+            self.meta = dict(page.local_meta)
+            self.out_links = list(page.local_links)
+            self.included_pages = list(page.local_includes)
 
-    def add(self, ctx):
-        self.url_trail += ctx.url_trail
-        self.out_links += ctx.out_links
-        self.included_pages += ctx.included_pages
-        for original_key, val in ctx.meta.iteritems():
+    def add(self, other):
+        self.out_links += other.out_links
+        self.included_pages += other.included_pages
+        for original_key, val in other.meta.iteritems():
             # Ignore internal properties. Strip include-only properties
             # from their prefix.
-            key = original_key
-            if key[0:2] == '__':
+            key, mod = get_meta_name_and_modifiers(original_key)
+            if mod == '__':
                 continue
-            if key[0] == '+':
-                key = key[1:]
 
             if key not in self.meta:
                 self.meta[key] = val
@@ -165,6 +222,9 @@
 
 
 class PageResolver(object):
+    """ An object responsible for resolving page queries like
+        `include` or `query`.
+    """
     default_parameters = {
         'header': "<ul>",
         'footer': "</ul>",
@@ -173,9 +233,10 @@
         'empty': "<p>No page matches the query.</p>"
         }
 
-    def __init__(self, page, ctx):
+    def __init__(self, page, ctx=None):
         self.page = page
         self.ctx = ctx
+        self.output = None
 
     @property
     def wiki(self):
@@ -185,45 +246,74 @@
         def repl(m):
             meta_name = str(m.group('name'))
             meta_value = str(m.group('value'))
+            meta_opts = {}
+            if m.group('opts'):
+                for c in re.finditer(
+                        r'data-wiki-(?P<name>[a-z]+)="(?P<value>[^"]+)"', 
+                        str(m.group('opts'))):
+                    opt_name = str(c.group('name'))
+                    opt_value = str(c.group('value'))
+                    meta_opts[opt_name] = opt_value
+
             if meta_name == 'query':
-                return self._runQuery(meta_value)
+                return self._runQuery(meta_opts, meta_value)
             elif meta_name == 'include':
-                return self._runInclude(str(m.group('url')), meta_value)
+                return self._runInclude(meta_opts, meta_value)
             return ''
 
-        self.ctx.url_trail = [self.page.url]
-        self.ctx.out_links = self.page.local_links
-        self.ctx.included_pages = self.page.local_includes
-        self.ctx.meta = self.page.local_meta
+        if not self.ctx:
+            self.ctx = ResolveContext(self.page.url)
 
-        text = self.page.formatted_text
-        return re.sub(r'^<div class="wiki-(?P<name>[a-z]+)"( data-wiki-url="(?P<url>[^"]+)")?>(?P<value>.*)</div>$', repl, text,
-            flags=re.MULTILINE)
+        self.output = ResolveOutput(self.page)
+        self.output.text = re.sub(r'^<div class="wiki-(?P<name>[a-z]+)"'
+                r'(?P<opts>( data-wiki-([a-z]+)="([^"]+)")*)'
+                r'>(?P<value>.*)</div>$',
+                repl, 
+                self.page.formatted_text,
+                flags=re.MULTILINE)
+        return self.output
 
-    def _runInclude(self, include_url, include_args):
+    def _runInclude(self, opts, args):
+        # Should we even run this include?
+        if 'mod' in opts:
+            if not self.ctx.shouldRunMeta(opts['mod']):
+                return ''
+
+        # Check for circular includes.
+        include_url = opts['url']
         if include_url in self.ctx.url_trail:
             raise CircularIncludeError("Circular include detected at: %s" % include_url, self.ctx.url_trail)
 
+        # Parse the templating parameters.
         parameters = None
-        if include_args:
+        if args:
             parameters = {}
             arg_pattern = r"(^|\|)(?P<name>[a-zA-Z][a-zA-Z0-9_\-]+)=(?P<value>[^\|]+)"
-            for m in re.finditer(arg_pattern, include_args):
+            for m in re.finditer(arg_pattern, args):
                 key = str(m.group('name')).lower()
                 parameters[key] = str(m.group('value'))
 
+        # Re-run the resolver on the included page to get its final
+        # formatted text.
         page = self.wiki.getPage(include_url)
-        child_ctx = ResolvingContext()
-        child = PageResolver(page, child_ctx)
-        text = child.run()
-        self.ctx.add(child_ctx)
+        self.ctx.url_trail.add(page.url)
+        child = PageResolver(page, self.ctx)
+        child_output = child.run()
+        self.output.add(child_output)
 
+        # Run some simple templating if we need to.
+        text = child_output.text
         if parameters:
             text = self._renderTemplate(text, parameters)
 
         return text
 
-    def _runQuery(self, query):
+    def _runQuery(self, opts, query):
+        # Should we even run this query?
+        if 'mod' in opts:
+            if not self.ctx.shouldRunMeta(opts['mod']):
+                return ''
+
         # Parse the query.
         parameters = dict(self.default_parameters)
         meta_query = {}
--- a/wikked/page.py	Thu Jan 31 22:41:07 2013 -0800
+++ b/wikked/page.py	Sat Feb 02 20:16:54 2013 -0800
@@ -3,10 +3,7 @@
 import re
 import datetime
 import unicodedata
-from formatter import (
-    PageFormatter, FormattingContext,
-    PageResolver, ResolvingContext
-    )
+from formatter import PageFormatter, FormattingContext, PageResolver
 
 
 class Page(object):
@@ -132,13 +129,13 @@
         if self._ext_meta is not None:
             return
 
+        r = PageResolver(self)
+        out = r.run()
         self._ext_meta = {}
-        ctx = ResolvingContext()
-        r = PageResolver(self, ctx)
-        self._ext_meta['text'] = r.run()
-        self._ext_meta['meta'] = ctx.meta
-        self._ext_meta['links'] = ctx.out_links
-        self._ext_meta['includes'] = ctx.included_pages
+        self._ext_meta['text'] = out.text
+        self._ext_meta['meta'] = out.meta
+        self._ext_meta['links'] = out.out_links
+        self._ext_meta['includes'] = out.included_pages
 
     @staticmethod
     def title_to_url(title):