changeset 6:f5ca5c5bed85

More Python 3 fixes, modularization, and new unit tests.
author Ludovic Chabant <ludovic@chabant.com>
date Sat, 16 Aug 2014 08:15:30 -0700
parents 474c9882decf
children 343d08ef5668
files piecrust/app.py piecrust/baking/baker.py piecrust/data/assetor.py piecrust/data/base.py piecrust/data/iterators.py piecrust/data/paginator.py piecrust/page.py piecrust/sources/base.py piecrust/sources/posts.py piecrust/uriutil.py tests/mockutil.py tests/test_baking_baker.py tests/test_data_assetor.py tests/test_data_paginator.py tests/test_pagebaker.py tests/test_server.py tests/test_serving.py
diffstat 17 files changed, 604 insertions(+), 166 deletions(-) [+]
line wrap: on
line diff
--- a/piecrust/app.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/app.py	Sat Aug 16 08:15:30 2014 -0700
@@ -1,7 +1,6 @@
 import re
 import json
 import os.path
-import types
 import codecs
 import hashlib
 import logging
@@ -59,8 +58,8 @@
             return
 
         path_times = [os.path.getmtime(p) for p in self.paths]
-        cache_key = hashlib.md5("version=%s&cache=%d" % (
-                APP_VERSION, CACHE_VERSION)).hexdigest()
+        cache_key = hashlib.md5(("version=%s&cache=%d" % (
+                APP_VERSION, CACHE_VERSION)).encode('utf8')).hexdigest()
 
         if self.cache.isValid('config.json', path_times):
             logger.debug("Loading configuration from cache...")
--- a/piecrust/baking/baker.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/baking/baker.py	Sat Aug 16 08:15:30 2014 -0700
@@ -61,13 +61,15 @@
 
     def getOutputPath(self, uri):
         bake_path = [self.out_dir]
-        decoded_uri = urllib.parse.unquote(uri.lstrip('/')).decode('utf8')
+        decoded_uri = urllib.parse.unquote(uri.lstrip('/'))
         if self.pretty_urls:
             bake_path.append(decoded_uri)
             bake_path.append('index.html')
         else:
             name, ext = os.path.splitext(decoded_uri)
-            if ext:
+            if decoded_uri == '':
+                bake_path.append('index.html')
+            elif ext:
                 bake_path.append(decoded_uri)
             else:
                 bake_path.append(decoded_uri + '.html')
@@ -191,7 +193,7 @@
             os.makedirs(out_dir, 0o755)
 
         with codecs.open(out_path, 'w', 'utf-8') as fp:
-            fp.write(rp.content.decode('utf-8'))
+            fp.write(rp.content)
 
         return ctx, rp
 
--- a/piecrust/data/assetor.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/data/assetor.py	Sat Aug 16 08:15:30 2014 -0700
@@ -1,3 +1,4 @@
+import os
 import os.path
 import logging
 from piecrust.uriutil import multi_replace
@@ -6,6 +7,10 @@
 logger = logging.getLogger(__name__)
 
 
+class UnsupportedAssetsError(Exception):
+    pass
+
+
 def build_base_url(app, uri, assets_path):
     base_url_format = app.env.base_asset_url_format
     site_root = app.config.get('site/root')
@@ -13,6 +18,7 @@
     pretty = app.config.get('site/pretty_urls')
     if not pretty:
         uri, _ = os.path.splitext(uri)
+    uri = uri.lstrip('/')
     base_url = multi_replace(
             base_url_format,
             {
@@ -71,5 +77,8 @@
         for _, __, filenames in os.walk(assets_dir):
             for fn in filenames:
                 name, ext = os.path.splitext(fn)
+                if name in self._cache:
+                    raise UnsupportedAssetsError(
+                            "Multiple asset files are named '%s'." % name)
                 self._cache[name] = base_url + fn
 
--- a/piecrust/data/base.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/data/base.py	Sat Aug 16 08:15:30 2014 -0700
@@ -6,6 +6,23 @@
 logger = logging.getLogger(__name__)
 
 
+class IPaginationSource(object):
+    def getItemsPerPage(self):
+        raise NotImplementedError()
+
+    def getSourceIterator(self):
+        raise NotImplementedError()
+
+    def getSorterIterator(self, it):
+        raise NotImplementedError()
+
+    def getTailIterator(self, it):
+        raise NotImplementedError()
+
+    def getPaginationFilter(self, page):
+        raise NotImplementedError()
+
+
 class LazyPageConfigData(object):
     """ An object that represents the configuration header of a page,
         but also allows for additional data. It's meant to be exposed
@@ -78,43 +95,19 @@
         pass
 
 
-def build_uri(page):
-    route = page.app.getRoute(page.source.name, page.source_metadata)
-    if route is None:
-        raise Exception("Can't get route for page: %s" % page.path)
-    return route.getUri(page.source_metadata)
-
-
-def load_rendered_segment(data, name):
-    from piecrust.rendering import PageRenderingContext, render_page_segments
-
-    uri = build_uri(data.page)
-    try:
-        ctx = PageRenderingContext(data.page, uri)
-        segs = render_page_segments(ctx)
-    except Exception as e:
-        logger.exception("Error rendering segments for '%s': %s" % (uri, e))
-        raise
-
-    for k, v in segs.items():
-        data.mapLoader(k, None)
-        data.setValue(k, v)
-
-    if 'content.abstract' in segs:
-        data.setValue('content', segs['content.abstract'])
-        data.setValue('has_more', True)
-        if name == 'content':
-            return segs['content.abstract']
-
-    return segs[name]
-
-
 class PaginationData(LazyPageConfigData):
     def __init__(self, page):
         super(PaginationData, self).__init__(page)
 
+    def _get_uri(self):
+        page = self._page
+        route = page.app.getRoute(page.source.name, page.source_metadata)
+        if route is None:
+            raise Exception("Can't get route for page: %s" % page.path)
+        return route.getUri(page.source_metadata)
+
     def _loadCustom(self):
-        page_url = build_uri(self.page)
+        page_url = self._get_uri()
         self.setValue('url', page_url)
         self.setValue('slug', page_url)
         self.setValue('timestamp',
@@ -128,5 +121,29 @@
 
         segment_names = self.page.config.get('segments')
         for name in segment_names:
-            self.mapLoader(name, load_rendered_segment)
+            self.mapLoader(name, self._load_rendered_segment)
+
+    def _load_rendered_segment(self, data, name):
+        from piecrust.rendering import PageRenderingContext, render_page_segments
 
+        assert self is data
+        uri = self._get_uri()
+        try:
+            ctx = PageRenderingContext(self._page, uri)
+            segs = render_page_segments(ctx)
+        except Exception as e:
+            logger.exception("Error rendering segments for '%s': %s" % (uri, e))
+            raise
+
+        for k, v in segs.items():
+            self.mapLoader(k, None)
+            self.setValue(k, v)
+
+        if 'content.abstract' in segs:
+            self.setValue('content', segs['content.abstract'])
+            self.setValue('has_more', True)
+            if name == 'content':
+                return segs['content.abstract']
+
+        return segs[name]
+
--- a/piecrust/data/iterators.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/data/iterators.py	Sat Aug 16 08:15:30 2014 -0700
@@ -1,5 +1,5 @@
 import logging
-from piecrust.data.base import PaginationData
+from piecrust.data.base import IPaginationSource
 from piecrust.data.filters import PaginationFilter
 from piecrust.events import Event
 
@@ -89,16 +89,6 @@
         return sorted(self.it, cmp=self._comparer, reverse=self.reverse)
 
 
-class DateSortIterator(object):
-    def __init__(self, it, reverse=True):
-        self.it = it
-        self.reverse = reverse
-
-    def __iter__(self):
-        return iter(sorted(self.it,
-                           key=lambda x: x.datetime, reverse=self.reverse))
-
-
 class PaginationFilterIterator(object):
     def __init__(self, it, fil):
         self.it = it
@@ -110,33 +100,13 @@
                 yield page
 
 
-class SourceFactoryIterator(object):
-    def __init__(self, source):
-        self.source = source
-        self.it = None # This is to permit recursive traversal of the
-                       # iterator chain. It acts as the end.
-
-    def __iter__(self):
-        for factory in self.source.getPageFactories():
-            yield factory.buildPage()
-
-
-class PaginationDataBuilderIterator(object):
-    def __init__(self, it):
-        self.it = it
-
-    def __iter__(self):
-        for page in self.it:
-            yield PaginationData(page)
-
-
 class PageIterator(object):
     def __init__(self, source, current_page=None, pagination_filter=None,
             offset=0, limit=-1, locked=False):
         self._source = source
         self._current_page = current_page
         self._locked = False
-        self._pages = SourceFactoryIterator(source)
+        self._pages = source
         self._pagesData = None
         self._pagination_slicer = None
         self._has_sorter = False
@@ -144,6 +114,11 @@
         self._prev_page = None
         self._iter_event = Event()
 
+        if isinstance(source, IPaginationSource):
+            src_it = source.getSourceIterator()
+            if src_it is not None:
+                self._pages = src_it
+
         # Apply any filter first, before we start sorting or slicing.
         if pagination_filter is not None:
             self._simpleNonSortedWrap(PaginationFilterIterator,
@@ -203,7 +178,7 @@
                 return self._simpleNonSortedWrap(SettingFilterIterator, conf)
             return has_filter
 
-        raise AttributeError()
+        return self.__getattribute__(name)
 
     def skip(self, count):
         return self._simpleWrap(SliceIterator, count)
@@ -268,7 +243,10 @@
     def _ensureSorter(self):
         if self._has_sorter:
             return
-        self._pages = DateSortIterator(self._pages)
+        if isinstance(self._source, IPaginationSource):
+            sort_it = self._source.getSorterIterator(self._pages)
+            if sort_it is not None:
+                self._pages = sort_it
         self._has_sorter = True
 
     def _unload(self):
@@ -282,10 +260,19 @@
 
         self._ensureSorter()
 
-        it_chain = PaginationDataBuilderIterator(self._pages)
+        it_chain = self._pages
+        is_pgn_source = False
+        if isinstance(self._source, IPaginationSource):
+            is_pgn_source = True
+            tail_it = self._source.getTailIterator(self._pages)
+            if tail_it is not None:
+                it_chain = tail_it
+
         self._pagesData = list(it_chain)
 
-        if self._current_page and self._pagination_slicer:
-            self._prev_page = PaginationData(self._pagination_slicer.prev_page)
-            self._next_page = PaginationData(self._pagination_slicer.next_page)
+        if is_pgn_source and self._current_page and self._pagination_slicer:
+            pn = [self._pagination_slicer.prev_page,
+                    self._pagination_slicer.next_page]
+            pn_it = self._source.getTailIterator(iter(pn))
+            self._prev_page, self._next_page = (list(pn_it))
 
--- a/piecrust/data/paginator.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/data/paginator.py	Sat Aug 16 08:15:30 2014 -0700
@@ -1,6 +1,7 @@
 import math
 import logging
 from werkzeug.utils import cached_property
+from piecrust.data.base import IPaginationSource
 from piecrust.data.filters import PaginationFilter
 from piecrust.data.iterators import PageIterator
 
@@ -82,8 +83,11 @@
 
     @cached_property
     def items_per_page(self):
-        return (self._parent_page.config.get('items_per_page') or
-            self._source.items_per_page)
+        if self._parent_page:
+            ipp = self._parent_page.config.get('items_per_page')
+            if ipp is not None:
+                return ipp
+        return self._source.getItemsPerPage()
 
     @property
     def items_this_page(self):
@@ -152,7 +156,7 @@
             return []
 
         if radius <= 0 or total_page_count < (2 * radius + 1):
-            return list(range(1, total_page_count))
+            return list(range(1, total_page_count + 1))
 
         first_num = self._page_num - radius
         last_num = self._page_num + radius
@@ -164,7 +168,7 @@
             last_num = total_page_count
         first_num = max(1, first_num)
         last_num = min(total_page_count, last_num)
-        return list(range(first_num, last_num))
+        return list(range(first_num, last_num + 1))
 
     def page(self, index):
         return self._getPageUri(index)
@@ -191,12 +195,10 @@
         if self._pgn_filter is not None:
             f.addClause(self._pgn_filter.root_clause)
 
-        conf = (self._parent_page.config.get('items_filters') or
-                self._parent_page.app.config.get('site/items_filters'))
-        if conf == 'none' or conf == 'nil' or conf == '':
-            conf = None
-        if conf is not None:
-            f.addClausesFromConfig(conf)
+        if isinstance(self._source, IPaginationSource):
+            sf = self._source.getPaginationFilter(self._parent_page)
+            if sf is not None:
+                f.addClause(sf)
 
         return f
 
@@ -209,7 +211,7 @@
         return uri
 
     def _onIteration(self):
-        if not self._pgn_set_on_ctx:
+        if self._parent_page is not None and not self._pgn_set_on_ctx:
             eis = self._parent_page.app.env.exec_info_stack
             eis.current_page_info.render_ctx.setPagination(self)
             self._pgn_set_on_ctx = True
--- a/piecrust/page.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/page.py	Sat Aug 16 08:15:30 2014 -0700
@@ -7,7 +7,6 @@
 import logging
 import datetime
 import dateutil.parser
-import threading
 from piecrust.configuration import (Configuration, ConfigurationError,
         parse_config_header)
 from piecrust.environment import PHASE_PAGE_PARSING
--- a/piecrust/sources/base.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/sources/base.py	Sat Aug 16 08:15:30 2014 -0700
@@ -5,6 +5,8 @@
 from werkzeug.utils import cached_property
 from piecrust import CONTENT_DIR
 from piecrust.configuration import ConfigurationError
+from piecrust.data.base import IPaginationSource, PaginationData
+from piecrust.data.filters import PaginationFilter
 from piecrust.page import Page
 
 
@@ -247,6 +249,31 @@
         raise NotImplementedError()
 
 
+class SimplePaginationSourceMixin(IPaginationSource):
+    def getItemsPerPage(self):
+        return self.config['items_per_page']
+
+    def getSourceIterator(self):
+        return SourceFactoryIterator(self)
+
+    def getSorterIterator(self, it):
+        return DateSortIterator(it)
+
+    def getTailIterator(self, it):
+        return PaginationDataBuilderIterator(it)
+
+    def getPaginationFilter(self, page):
+        conf = (page.config.get('items_filters') or
+                page.app.config.get('site/items_filters'))
+        if conf == 'none' or conf == 'nil' or conf == '':
+            conf = None
+        if conf is not None:
+            f = PaginationFilter()
+            f.addClausesFromConfig(conf)
+            return f
+        return None
+
+
 class ArraySource(PageSource):
     def __init__(self, app, inner_source, name='array', config=None):
         super(ArraySource, self).__init__(app, name, config or {})
@@ -325,7 +352,8 @@
                 f not in ['Thumbs.db'])
 
 
-class DefaultPageSource(SimplePageSource, IPreparingSource):
+class DefaultPageSource(SimplePageSource, IPreparingSource,
+        SimplePaginationSourceMixin):
     SOURCE_NAME = 'default'
 
     def __init__(self, app, name, config):
@@ -337,3 +365,33 @@
     def buildMetadata(self, args):
         return {'path': args.uri}
 
+
+class SourceFactoryIterator(object):
+    def __init__(self, source):
+        self.source = source
+        self.it = None # This is to permit recursive traversal of the
+                       # iterator chain. It acts as the end.
+
+    def __iter__(self):
+        for factory in self.source.getPageFactories():
+            yield factory.buildPage()
+
+
+class DateSortIterator(object):
+    def __init__(self, it, reverse=True):
+        self.it = it
+        self.reverse = reverse
+
+    def __iter__(self):
+        return iter(sorted(self.it,
+                           key=lambda x: x.datetime, reverse=self.reverse))
+
+
+class PaginationDataBuilderIterator(object):
+    def __init__(self, it):
+        self.it = it
+
+    def __iter__(self):
+        for page in self.it:
+            yield PaginationData(page)
+
--- a/piecrust/sources/posts.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/sources/posts.py	Sat Aug 16 08:15:30 2014 -0700
@@ -6,6 +6,7 @@
 import datetime
 from piecrust import CONTENT_DIR
 from piecrust.sources.base import (PageSource, IPreparingSource,
+        SimplePaginationSourceMixin,
         PageNotFoundError, InvalidFileSystemEndpointError,
         PageFactory, MODE_CREATING)
 
@@ -13,7 +14,7 @@
 logger = logging.getLogger(__name__)
 
 
-class PostsSource(PageSource, IPreparingSource):
+class PostsSource(PageSource, IPreparingSource, SimplePaginationSourceMixin):
     PATH_FORMAT = None
 
     def __init__(self, app, name, config):
--- a/piecrust/uriutil.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/piecrust/uriutil.py	Sat Aug 16 08:15:30 2014 -0700
@@ -38,7 +38,7 @@
 
 
 def parse_uri(routes, uri):
-    if string.find(uri, '..') >= 0:
+    if uri.find('..') >= 0:
         raise UriError(uri)
 
     page_num = 1
--- a/tests/mockutil.py	Mon Aug 11 22:36:47 2014 -0700
+++ b/tests/mockutil.py	Sat Aug 16 08:15:30 2014 -0700
@@ -1,9 +1,190 @@
+import io
+import time
+import random
+import codecs
+import os.path
+import functools
 import mock
+import yaml
 from piecrust.app import PieCrust, PieCrustConfiguration
 
 
+resources_path = os.path.abspath(
+            os.path.join(
+            os.path.dirname(__file__),
+            '..', 'piecrust', 'resources'))
+
+
 def get_mock_app(config=None):
     app = mock.MagicMock(spec=PieCrust)
     app.config = PieCrustConfiguration()
     return app
 
+
+def with_mock_fs_app(f):
+    @functools.wraps(f)
+    def wrapper(app, *args, **kwargs):
+        with mock_fs_scope(app):
+            real_app = app.getApp()
+            return f(real_app, *args, **kwargs)
+    return wrapper
+
+
+class mock_fs(object):
+    def __init__(self, default_spec=True):
+        self._root = 'root_%d' % random.randrange(1000)
+        self._fs = {self._root: {}}
+        if default_spec:
+            self.withDir('counter')
+            self.withFile('kitchen/_content/config.yml',
+                    "site:\n  title: Mock Website\n")
+
+    def path(self, p):
+        if p in ['/', '', None]:
+            return '/%s' % self._root
+        return '/%s/%s' % (self._root, p.lstrip('/'))
+
+    def getApp(self):
+        root_dir = self.path('/kitchen')
+        return PieCrust(root_dir, cache=False)
+
+    def withDir(self, path):
+        cur = self._fs[self._root]
+        for b in path.split('/'):
+            if b not in cur:
+                cur[b] = {}
+            cur = cur[b]
+        return self
+
+    def withFile(self, path, contents):
+        cur = self._fs[self._root]
+        bits = path.split('/')
+        for b in bits[:-1]:
+            if b not in cur:
+                cur[b] = {}
+            cur = cur[b]
+        cur[bits[-1]] = (contents, {'mtime': time.time()})
+        return self
+
+    def withAsset(self, path, contents):
+        return self.withFile('kitchen/' + path, contents)
+
+    def withAssetDir(self, path):
+        return self.withDir('kitchen/' + path)
+
+    def withConfig(self, config):
+        return self.withFile('kitchen/_content/config.yml',
+                yaml.dump(config))
+
+    def withThemeConfig(self, config):
+        return self.withFile('kitchen/_content/theme/_content/theme_config.yml',
+                yaml.dump(config))
+
+    def withPage(self, url, config=None, contents=None):
+        config = config or {}
+        contents = contents or "A test page."
+        text = "---\n"
+        text += yaml.dump(config)
+        text += "---\n"
+        text += contents
+
+        name, ext = os.path.splitext(url)
+        if not ext:
+            url += '.md'
+        url = url.lstrip('/')
+        return self.withAsset('_content/pages/' + url, text)
+
+    def withPageAsset(self, page_url, name, contents=None):
+        contents = contents or "A test asset."
+        url_base, ext = os.path.splitext(page_url)
+        dirname = url_base + '-assets'
+        return self.withAsset('_content/pages/%s/%s' % (dirname, name),
+                contents)
+
+
+class mock_fs_scope(object):
+    def __init__(self, fs):
+        self._fs = fs
+        self._root = None
+        self._patchers = []
+        self._originals = {}
+        if isinstance(fs, mock_fs):
+            self._fs = fs._fs
+            self._root = fs._root
+
+    def __enter__(self):
+        self._startMock()
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self._endMock()
+
+    def _startMock(self):
+        self._createMock('__main__.open', open, self._open, create=True)
+        self._createMock('codecs.open', codecs.open, self._codecsOpen)
+        self._createMock('os.listdir', os.listdir, self._listdir)
+        self._createMock('os.path.isdir', os.path.isdir, self._isdir)
+        self._createMock('os.path.islink', os.path.islink, self._islink)
+        self._createMock('os.path.getmtime', os.path.getmtime, self._getmtime)
+        for p in self._patchers:
+            p.start()
+
+    def _endMock(self):
+        for p in self._patchers:
+            p.stop()
+
+    def _createMock(self, name, orig, func, **kwargs):
+        self._originals[name] = orig
+        self._patchers.append(mock.patch(name, func, **kwargs))
+
+    def _open(self, path, *args, **kwargs):
+        path = os.path.abspath(path)
+        if path.startswith(resources_path):
+            return self._originals['__main__.open'](path, **kwargs)
+        e = self._getFsEntry(path)
+        return io.StringIO(e[0])
+
+    def _codecsOpen(self, path, *args, **kwargs):
+        path = os.path.abspath(path)
+        if path.startswith(resources_path):
+            return self._originals['codecs.open'](path, *args, **kwargs)
+        e = self._getFsEntry(path)
+        return io.StringIO(e[0])
+
+    def _listdir(self, path):
+        if not path.startswith('/' + self._root):
+            return self._originals['os.listdir'](path)
+        e = self._getFsEntry(path)
+        if not isinstance(e, dict):
+            raise Exception("'%s' is not a directory." % path)
+        return list(e.keys())
+
+    def _isdir(self, path):
+        if not path.startswith('/' + self._root):
+            return self._originals['os.path.isdir'](path)
+        e = self._getFsEntry(path)
+        return e is not None and isinstance(e, dict)
+
+    def _islink(self, path):
+        if not path.startswith('/' + self._root):
+            return self._originals['os.path.islink'](path)
+        return False
+
+    def _getmtime(self, path):
+        if not path.startswith('/' + self._root):
+            return self._originals['os.path.getmtime'](path)
+        e = self._getFsEntry(path)
+        if e is None:
+            raise OSError()
+        return e[1]['mtime']
+
+    def _getFsEntry(self, path):
+        cur = self._fs
+        bits = path.lstrip('/').split('/')
+        for p in bits:
+            try:
+                cur = cur[p]
+            except KeyError:
+                return None
+        return cur
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_baking_baker.py	Sat Aug 16 08:15:30 2014 -0700
@@ -0,0 +1,41 @@
+import pytest
+from piecrust.baking.baker import PageBaker
+from .mockutil import get_mock_app
+
+
+@pytest.mark.parametrize('uri, page_num, pretty, expected', [
+        # Pretty URLs
+        ('', 1, True, 'index.html'),
+        ('', 2, True, '2/index.html'),
+        ('foo', 1, True, 'foo/index.html'),
+        ('foo', 2, True, 'foo/2/index.html'),
+        ('foo/bar', 1, True, 'foo/bar/index.html'),
+        ('foo/bar', 2, True, 'foo/bar/2/index.html'),
+        ('foo.ext', 1, True, 'foo.ext/index.html'),
+        ('foo.ext', 2, True, 'foo.ext/2/index.html'),
+        ('foo.bar.ext', 1, True, 'foo.bar.ext/index.html'),
+        ('foo.bar.ext', 2, True, 'foo.bar.ext/2/index.html'),
+        # Ugly URLs
+        ('', 1, False, 'index.html'),
+        ('', 2, False, '2.html'),
+        ('foo', 1, False, 'foo.html'),
+        ('foo', 2, False, 'foo/2.html'),
+        ('foo/bar', 1, False, 'foo/bar.html'),
+        ('foo/bar', 2, False, 'foo/bar/2.html'),
+        ('foo.ext', 1, False, 'foo.ext'),
+        ('foo.ext', 2, False, 'foo/2.ext'),
+        ('foo.bar.ext', 1, False, 'foo.bar.ext'),
+        ('foo.bar.ext', 2, False, 'foo.bar/2.ext')
+        ])
+def test_get_output_path(uri, page_num, pretty, expected):
+    app = get_mock_app()
+    if pretty:
+        app.config.set('site/pretty_urls', True)
+    assert app.config.get('site/pretty_urls') == pretty
+
+    baker = PageBaker(app, '/destination')
+    sub_uri = baker.getOutputUri(uri, page_num)
+    path = baker.getOutputPath(sub_uri)
+    expected = '/destination/' + expected
+    assert expected == path
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_data_assetor.py	Sat Aug 16 08:15:30 2014 -0700
@@ -0,0 +1,55 @@
+import pytest
+from mock import MagicMock
+from piecrust.data.assetor import Assetor, UnsupportedAssetsError
+from .mockutil import mock_fs, mock_fs_scope
+
+
+@pytest.mark.parametrize('fs, expected', [
+        (mock_fs().withPage('foo/bar'), {}),
+        (mock_fs()
+            .withPage('foo/bar')
+            .withPageAsset('foo/bar', 'one.txt', 'one'),
+            {'one': 'one'}),
+        (mock_fs()
+            .withPage('foo/bar')
+            .withPageAsset('foo/bar', 'one.txt', 'one')
+            .withPageAsset('foo/bar', 'two.txt', 'two'),
+            {'one': 'one', 'two': 'two'})
+        ])
+def test_assets(fs, expected):
+    with mock_fs_scope(fs):
+        page = MagicMock()
+        page.app = fs.getApp()
+        page.path = fs.path('/kitchen/_content/pages/foo/bar.md')
+        assetor = Assetor(page, '/foo/bar')
+        for en in expected.keys():
+            assert hasattr(assetor, en)
+            path = '/foo/bar/%s.txt' % en
+            assert getattr(assetor, en) == path
+            assert assetor[en] == path
+
+
+def test_missing_asset():
+    with pytest.raises(KeyError):
+        fs = mock_fs().withPage('foo/bar')
+        with mock_fs_scope(fs):
+            page = MagicMock()
+            page.app = fs.getApp()
+            page.path = fs.path('/kitchen/_content/pages/foo/bar.md')
+            assetor = Assetor(page, '/foo/bar')
+            assetor['this_doesnt_exist']
+
+
+def test_multiple_assets_with_same_name():
+    with pytest.raises(UnsupportedAssetsError):
+        fs = (mock_fs()
+                .withPage('foo/bar')
+                .withPageAsset('foo/bar', 'one.txt', 'one text')
+                .withPageAsset('foo/bar', 'one.jpg', 'one picture'))
+        with mock_fs_scope(fs):
+            page = MagicMock()
+            page.app = fs.getApp()
+            page.path = fs.path('/kitchen/_content/pages/foo/bar.md')
+            assetor = Assetor(page, '/foo/bar')
+            assetor['one']
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_data_paginator.py	Sat Aug 16 08:15:30 2014 -0700
@@ -0,0 +1,127 @@
+import math
+import pytest
+from piecrust.data.base import IPaginationSource
+from piecrust.data.paginator import Paginator
+
+
+class MockSource(list, IPaginationSource):
+    def __init__(self, count):
+        for i in range(count):
+            self.append('item %d' % i)
+
+    def getItemsPerPage(self):
+        return 5
+
+    def getSourceIterator(self):
+        return None
+
+    def getSorterIterator(self, it):
+        return None
+
+    def getTailIterator(self, it):
+        return None
+
+    def getPaginationFilter(self, page):
+        return None
+
+
+@pytest.mark.parametrize('uri, page_num, count', [
+        ('', 1, 0),
+        ('', 1, 4),
+        ('', 1, 5),
+        ('', 1, 8),
+        ('', 1, 14),
+        ('', 2, 8),
+        ('', 2, 14),
+        ('', 3, 14),
+        ('blog', 1, 0),
+        ('blog', 1, 4),
+        ('blog', 1, 5),
+        ('blog', 1, 8),
+        ('blog', 1, 14),
+        ('blog', 2, 8),
+        ('blog', 2, 14),
+        ('blog', 3, 14)
+    ])
+def test_paginator(uri, page_num, count):
+    source = MockSource(count)
+    p = Paginator(None, source, uri, page_num)
+
+    if count <= 5:
+        # All posts fit on the page
+        assert p.prev_page_number is None
+        assert p.prev_page is None
+        assert p.this_page_number == 1
+        assert p.this_page == uri
+        assert p.next_page_number is None
+        assert p.next_page is None
+    elif page_num == 1:
+        # First page in many
+        assert p.prev_page_number is None
+        assert p.prev_page is None
+        assert p.this_page_number == 1
+        assert p.this_page == uri
+        assert p.next_page_number == 2
+        np = '2' if uri == '' else (uri + '/2')
+        assert p.next_page == np
+    else:
+        # Page in the middle of it all
+        assert p.prev_page_number == page_num - 1
+        if page_num == 2:
+            assert p.prev_page == uri
+        else:
+            pp = str(page_num - 1) if uri == '' else (
+                    '%s/%d' % (uri, page_num - 1))
+            assert p.prev_page == pp
+
+        assert p.this_page_number == page_num
+        tp = str(page_num) if uri == '' else (
+                '%s/%d' % (uri, page_num))
+        assert p.this_page == tp
+
+        if page_num * 5 > count:
+            assert p.next_page_number is None
+            assert p.next_page is None
+        else:
+            assert p.next_page_number == page_num + 1
+            np = str(page_num + 1) if uri == '' else (
+                    '%s/%d' % (uri, page_num + 1))
+            assert p.next_page == np
+
+    assert p.total_post_count == count
+    page_count = math.ceil(count / 5.0)
+    assert p.total_page_count == page_count
+    assert p.all_page_numbers() == list(range(1, page_count + 1))
+
+    for radius in range(1, 8):
+        width = radius * 2 + 1
+        if page_count == 0:
+            nums = []
+        else:
+            nums = list(filter(
+                lambda i: i >= 1 and i <= page_count,
+                range(page_num - radius, page_num + radius + 1)))
+            if len(nums) < width:
+                to_add = width - len(nums)
+                if nums[0] > 1:
+                    to_add = min(to_add, nums[0] - 1)
+                    nums = list(range(1, to_add + 1)) + nums
+                else:
+                    to_add = min(to_add, page_count - nums[-1])
+                    nums = nums + list(range(nums[-1] + 1, nums[-1] + to_add + 1))
+        assert nums == p.all_page_numbers(radius)
+
+    itp = count
+    if count > 5:
+        if page_num * 5 < count:
+            itp = 5
+        else:
+            itp = count % 5
+    assert p.items_this_page == itp
+
+    indices = list(range(count))
+    indices = indices[(page_num - 1) * 5 : (page_num - 1) * 5 + itp]
+    expected = list(['item %d' % i for i in indices])
+    items = list(p.items)
+    assert items == expected
+
--- a/tests/test_pagebaker.py	Mon Aug 11 22:36:47 2014 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,40 +0,0 @@
-import pytest
-from piecrust.baking.baker import PageBaker
-from .mockutil import get_mock_app
-
-
-@pytest.mark.parametrize('uri, page_num, pretty, expected', [
-        # Pretty URLs
-        ('', 1, True, 'index.html'),
-        ('', 2, True, '2/index.html'),
-        ('foo', 1, True, 'foo/index.html'),
-        ('foo', 2, True, 'foo/2/index.html'),
-        ('foo/bar', 1, True, 'foo/bar/index.html'),
-        ('foo/bar', 2, True, 'foo/bar/2/index.html'),
-        ('foo.ext', 1, True, 'foo.ext/index.html'),
-        ('foo.ext', 2, True, 'foo.ext/2/index.html'),
-        ('foo.bar.ext', 1, True, 'foo.bar.ext/index.html'),
-        ('foo.bar.ext', 2, True, 'foo.bar.ext/2/index.html'),
-        # Ugly URLs
-        ('', 1, False, 'index.html'),
-        ('', 2, False, '2.html'),
-        ('foo', 1, False, 'foo.html'),
-        ('foo', 2, False, 'foo/2.html'),
-        ('foo/bar', 1, False, 'foo/bar.html'),
-        ('foo/bar', 2, False, 'foo/bar/2.html'),
-        ('foo.ext', 1, False, 'foo.ext'),
-        ('foo.ext', 2, False, 'foo/2.ext'),
-        ('foo.bar.ext', 1, False, 'foo.bar.ext'),
-        ('foo.bar.ext', 2, False, 'foo.bar/2.ext')
-        ])
-def test_get_output_path(uri, page_num, pretty, expected):
-    app = get_mock_app()
-    if pretty:
-        app.config.set('site/pretty_urls', True)
-    assert app.config.get('site/pretty_urls') == pretty
-
-    baker = PageBaker(app, '/destination')
-    path = baker.getOutputPath(uri, page_num)
-    expected = '/destination/' + expected
-    assert expected == path
-
--- a/tests/test_server.py	Mon Aug 11 22:36:47 2014 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,34 +0,0 @@
-import re
-import pytest
-import mock
-from piecrust.serving import find_routes
-from piecrust.sources.base import REALM_USER, REALM_THEME
-
-
-@pytest.mark.parametrize('uri, route_specs, expected',
-        [
-            ('/',
-                [{'src': 'pages', 'pat': '(?P<path>.*)'}],
-                [('pages', {'path': ''})]),
-            ('/',
-                [{'src': 'pages', 'pat': '(?P<path>.*)'},
-                    {'src': 'theme', 'pat': '(?P<path>.*)', 'realm': REALM_THEME}],
-                [('pages', {'path': ''}), ('theme', {'path': ''})])
-            ])
-def test_find_routes(uri, route_specs, expected):
-    routes = []
-    for rs in route_specs:
-        m = mock.Mock()
-        m.source_name = rs['src']
-        m.source_realm = rs.setdefault('realm', REALM_USER)
-        m.uri_re = re.compile(rs['pat'])
-        routes.append(m)
-    matching = find_routes(routes, uri)
-
-    assert len(matching) == len(expected)
-    for i in range(len(matching)):
-        route, metadata = matching[i]
-        exp_source, exp_md = expected[i]
-        assert route.source_name == exp_source
-        assert metadata == exp_md
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_serving.py	Sat Aug 16 08:15:30 2014 -0700
@@ -0,0 +1,34 @@
+import re
+import pytest
+import mock
+from piecrust.serving import find_routes
+from piecrust.sources.base import REALM_USER, REALM_THEME
+
+
+@pytest.mark.parametrize('uri, route_specs, expected',
+        [
+            ('/',
+                [{'src': 'pages', 'pat': '(?P<path>.*)'}],
+                [('pages', {'path': ''})]),
+            ('/',
+                [{'src': 'pages', 'pat': '(?P<path>.*)'},
+                    {'src': 'theme', 'pat': '(?P<path>.*)', 'realm': REALM_THEME}],
+                [('pages', {'path': ''}), ('theme', {'path': ''})])
+            ])
+def test_find_routes(uri, route_specs, expected):
+    routes = []
+    for rs in route_specs:
+        m = mock.Mock()
+        m.source_name = rs['src']
+        m.source_realm = rs.setdefault('realm', REALM_USER)
+        m.uri_re = re.compile(rs['pat'])
+        routes.append(m)
+    matching = find_routes(routes, uri)
+
+    assert len(matching) == len(expected)
+    for i in range(len(matching)):
+        route, metadata = matching[i]
+        exp_source, exp_md = expected[i]
+        assert route.source_name == exp_source
+        assert metadata == exp_md
+