# HG changeset patch # User Ludovic Chabant # Date 1359865014 28800 # Node ID 9dfbc2a40b71ebcde66769f31a5bb219d02e5d8b # Parent c4e999f55ba9388cce82dd78a79caadb1eaea520 Formatter changes: - Refactored `PageResolver` with something that makes more sense. - Fixed some bugs with advanced include/meta scenarios. - Added more tests. diff -r c4e999f55ba9 -r 9dfbc2a40b71 tests/__init__.py --- 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 = '' + return res + +def format_include(url, args=None, mod=None): + res = '
Sandbox. Or to this page.", 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
\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
\n", foo.formatted_text) - self.assertEqual("A test page.\nBLAH: Somewhere\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
name=Dave|what=drink
\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( + "" % (format_link('Base', 'base'), format_link('Wrong', 'wrong')), + q1.text) + q2 = Page(self.wiki, 'query-2') + self.assertEqual( + "" % format_link('Base', 'base'), + q2.text) + diff -r c4e999f55ba9 -r 9dfbc2a40b71 wikked/formatter.py --- 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 '
%s
\n' % (included_url, parameters) + url_attr = ' data-wiki-url="%s"' % included_url + mod_attr = '' + if modifier: + mod_attr = ' data-wiki-mod="%s"' % modifier + return '
%s
\n' % (url_attr, mod_attr, parameters) - def _processQuery(self, ctx, query): + def _processQuery(self, ctx, modifier, query): # Queries are run on the fly. - return '
%s
\n' % query + mod_attr = '' + if modifier: + mod_attr = ' data-wiki-mod="%s"' % modifier + return '
%s
\n' % (mod_attr, query) def _formatWikiLink(self, ctx, display, url): abs_url = ctx.getAbsoluteUrl(url) @@ -136,25 +174,44 @@ return '%s' % (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': "", @@ -173,9 +233,10 @@ 'empty': "

No page matches the query.

" } - 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[a-z]+)="(?P[^"]+)"', + 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'^
(?P.*)
$', repl, text, - flags=re.MULTILINE) + self.output = ResolveOutput(self.page) + self.output.text = re.sub(r'^
( data-wiki-([a-z]+)="([^"]+)")*)' + r'>(?P.*)
$', + 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[a-zA-Z][a-zA-Z0-9_\-]+)=(?P[^\|]+)" - 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 = {} diff -r c4e999f55ba9 -r 9dfbc2a40b71 wikked/page.py --- 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):