changeset 369:4b1019bb2533

serve: Giant refactor to change how we handle data when serving pages. * We need a distinction between source metadata and route metadata. In most cases they're the same, but in cases like taxonomy pages, route metadata contains more things that can't be in source metadata if we want to re-use cached pages. * Create a new `QualifiedPage` type which is a page with a specific route and route metadata. Pass this around in many places. * Instead of passing an URL around, use the route in the `QualifiedPage` to generate URLs. This is better since it removes the guess-work from trying to generate URLs for sub-pages. * Deep-copy app and page configurations before passing them around to things that could modify them, like data builders and such. * Exclude taxonomy pages from iterator data providers. * Properly nest iterator data providers for when the theme and user page sources are merged inside `site.pages`.
author Ludovic Chabant <ludovic@chabant.com>
date Sun, 03 May 2015 18:47:10 -0700
parents 2408eb6f4da8
children a1bbe66cba03
files piecrust/app.py piecrust/baking/baker.py piecrust/baking/single.py piecrust/data/base.py piecrust/data/builder.py piecrust/data/paginator.py piecrust/data/provider.py piecrust/rendering.py piecrust/routing.py piecrust/serving.py piecrust/sources/base.py piecrust/sources/mixins.py piecrust/sources/pageref.py piecrust/sources/prose.py tests/bakes/test_data_provider.bake tests/bakes/test_pagination.bake tests/bakes/test_simple.bake tests/mockutil.py tests/test_data_paginator.py tests/test_data_provider.py tests/test_routing.py tests/test_serving.py tests/test_templating_jinjaengine.py tests/test_templating_pystacheengine.py
diffstat 24 files changed, 286 insertions(+), 136 deletions(-) [+]
line wrap: on
line diff
--- a/piecrust/app.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/app.py	Sun May 03 18:47:10 2015 -0700
@@ -537,11 +537,11 @@
                 if not skip_taxonomies or route.taxonomy_name is None:
                     yield route
 
-    def getRoute(self, source_name, source_metadata, *, skip_taxonomies=False):
+    def getRoute(self, source_name, route_metadata, *, skip_taxonomies=False):
         for route in self.getRoutes(source_name,
                                     skip_taxonomies=skip_taxonomies):
-            if (source_metadata is None or
-                    route.matchesMetadata(source_metadata)):
+            if (route_metadata is None or
+                    route.matchesMetadata(route_metadata)):
                 return route
         return None
 
--- a/piecrust/baking/baker.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/baking/baker.py	Sun May 03 18:47:10 2015 -0700
@@ -277,18 +277,13 @@
                             tax.page_ref)
                     continue
 
-                tax_page_source = tax_page_ref.source
-                tax_page_rel_path = tax_page_ref.rel_path
                 logger.debug(
                         "Using taxonomy page: %s:%s" %
-                        (tax_page_source.name, tax_page_rel_path))
-
+                        (tax_page_ref.source_name, tax_page_ref.rel_path))
                 for term in terms:
-                    fac = PageFactory(
-                            tax_page_source, tax_page_rel_path,
-                            {tax.term_name: term})
+                    fac = tax_page_ref.getFactory()
                     logger.debug(
-                            "Queuing: %s [%s, %s]" %
+                            "Queuing: %s [%s=%s]" %
                             (fac.ref_spec, tax_name, term))
                     entry = BakeRecordPageEntry(
                             fac.source.name, fac.rel_path, fac.path,
--- a/piecrust/baking/single.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/baking/single.py	Sun May 03 18:47:10 2015 -0700
@@ -1,4 +1,5 @@
 import os.path
+import copy
 import shutil
 import codecs
 import logging
@@ -10,7 +11,7 @@
         IsFilterClause, AndBooleanClause,
         page_value_accessor)
 from piecrust.rendering import (
-        PageRenderingContext, render_page,
+        QualifiedPage, PageRenderingContext, render_page,
         PASS_FORMATTING, PASS_RENDERING)
 from piecrust.sources.base import (
         PageFactory,
@@ -22,7 +23,7 @@
 
 
 def copy_public_page_config(config):
-    res = config.get().copy()
+    res = config.getDeepcopy()
     for k in list(res.keys()):
         if k.startswith('__'):
             del res[k]
@@ -60,10 +61,12 @@
         return os.path.normpath(os.path.join(*bake_path))
 
     def bake(self, factory, route, record_entry):
+        # Get the page.
+        page = factory.buildPage()
+        route_metadata = copy.deepcopy(factory.metadata)
+
+        # Add taxonomy info in the template data and route metadata if needed.
         bake_taxonomy_info = None
-        route_metadata = dict(factory.metadata)
-
-        # Add taxonomy metadata for generating the URL if needed.
         if record_entry.taxonomy_info:
             tax_name, tax_term, tax_source_name = record_entry.taxonomy_info
             taxonomy = self.app.getTaxonomy(tax_name)
@@ -71,8 +74,7 @@
             route_metadata[taxonomy.term_name] = slugified_term
             bake_taxonomy_info = (taxonomy, tax_term)
 
-        # Generate the URL using the route.
-        page = factory.buildPage()
+        # Generate the URI.
         uri = route.getUri(route_metadata, provider=page)
 
         # See if this URL has been overriden by a previously baked page.
@@ -209,7 +211,8 @@
                         BakeRecordSubPageEntry.FLAG_FORMATTING_INVALIDATED
 
                 logger.debug("  p%d -> %s" % (cur_sub, out_path))
-                ctx, rp = self._bakeSingle(page, sub_uri, cur_sub, out_path,
+                qp = QualifiedPage(page, route, route_metadata)
+                ctx, rp = self._bakeSingle(qp, cur_sub, out_path,
                                            bake_taxonomy_info)
             except Exception as ex:
                 if self.app.debug:
@@ -257,10 +260,8 @@
                     cur_sub += 1
                     has_more_subs = True
 
-    def _bakeSingle(self, page, sub_uri, num, out_path,
-                    taxonomy_info=None):
-        ctx = PageRenderingContext(page, sub_uri)
-        ctx.page_num = num
+    def _bakeSingle(self, qualified_page, num, out_path, taxonomy_info=None):
+        ctx = PageRenderingContext(qualified_page, page_num=num)
         if taxonomy_info:
             ctx.setTaxonomyFilter(taxonomy_info[0], taxonomy_info[1])
 
--- a/piecrust/data/base.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/data/base.py	Sun May 03 18:47:10 2015 -0700
@@ -1,3 +1,4 @@
+import copy
 import time
 import logging
 from piecrust.data.assetor import Assetor
@@ -97,7 +98,7 @@
     def _load(self):
         if self._values is not None:
             return
-        self._values = dict(self._page.config.get())
+        self._values = self._page.config.getDeepcopy(self._page.app.debug)
         try:
             self._loadCustom()
         except Exception as ex:
@@ -119,13 +120,20 @@
 class PaginationData(LazyPageConfigData):
     def __init__(self, page):
         super(PaginationData, self).__init__(page)
+        self._route = None
+        self._route_metadata = None
 
     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, provider=page)
+        if self._route is None:
+            # TODO: this is not quite correct, as we're missing parts of the
+            #       route metadata if the current page is a taxonomy page.
+            self._route = page.app.getRoute(page.source.name,
+                                            page.source_metadata)
+            self._route_metadata = copy.deepcopy(page.source_metadata)
+            if self._route is None:
+                raise Exception("Can't get route for page: %s" % page.path)
+        return self._route.getUri(self._route_metadata, provider=page)
 
     def _loadCustom(self):
         page_url = self._get_uri()
@@ -161,8 +169,11 @@
             uri = self._get_uri()
             try:
                 from piecrust.rendering import (
-                        PageRenderingContext, render_page_segments)
-                ctx = PageRenderingContext(self._page, uri)
+                        QualifiedPage, PageRenderingContext,
+                        render_page_segments)
+                qp = QualifiedPage(self._page, self._route,
+                                   self._route_metadata)
+                ctx = PageRenderingContext(qp)
                 segs = render_page_segments(ctx)
             except Exception as e:
                 raise Exception(
--- a/piecrust/data/builder.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/data/builder.py	Sun May 03 18:47:10 2015 -0700
@@ -1,22 +1,23 @@
 import re
 import time
+import copy
 import logging
+from werkzeug.utils import cached_property
 from piecrust import APP_VERSION
 from piecrust.configuration import merge_dicts
 from piecrust.data.assetor import Assetor
 from piecrust.data.debug import build_debug_info
 from piecrust.data.linker import PageLinkerData
 from piecrust.data.paginator import Paginator
-from piecrust.uriutil import split_uri, split_sub_uri
+from piecrust.uriutil import split_sub_uri
 
 
 logger = logging.getLogger(__name__)
 
 
 class DataBuildingContext(object):
-    def __init__(self, page, uri, page_num=1):
-        self.page = page
-        self.uri = uri
+    def __init__(self, qualified_page, page_num=1):
+        self.page = qualified_page
         self.page_num = page_num
         self.pagination_source = None
         self.pagination_filter = None
@@ -25,29 +26,34 @@
     def app(self):
         return self.page.app
 
+    @cached_property
+    def uri(self):
+        return self.page.getUri(self.page_num)
+
 
 def build_page_data(ctx):
+    app = ctx.app
     page = ctx.page
-    app = page.app
     first_uri, _ = split_sub_uri(app, ctx.uri)
-    _, slug = split_uri(app, ctx.uri)
 
     pc_data = PieCrustData()
     pgn_source = ctx.pagination_source or get_default_pagination_source(page)
-    paginator = Paginator(page, pgn_source, ctx.page_num,
-                          ctx.pagination_filter)
+    paginator = Paginator(page, pgn_source,
+                          page_num=ctx.page_num,
+                          pgn_filter=ctx.pagination_filter)
     assetor = Assetor(page, first_uri)
     linker = PageLinkerData(page.source, page.rel_path)
     data = {
             'piecrust': pc_data,
-            'page': dict(page.config.get()),
+            'page': {},
             'assets': assetor,
             'pagination': paginator,
             'family': linker
             }
     page_data = data['page']
+    page_data.update(copy.deepcopy(page.source_metadata))
+    page_data.update(page.config.getDeepcopy(app.debug))
     page_data['url'] = ctx.uri
-    page_data['slug'] = slug
     page_data['timestamp'] = time.mktime(page.datetime.timetuple())
     date_format = app.config.get('site/date_format')
     if date_format:
@@ -68,13 +74,11 @@
 
 
 def build_layout_data(page, page_data, contents):
-    data = dict(page_data)
     for name, txt in contents.items():
-        if name in data:
+        if name in page_data:
             logger.warning("Content segment '%s' will hide existing data." %
-                    name)
-        data[name] = txt
-    return data
+                           name)
+        page_data[name] = txt
 
 
 class PieCrustData(object):
@@ -109,7 +113,7 @@
 
 def build_site_data(page):
     app = page.app
-    data = dict(app.config.get())
+    data = app.config.getDeepcopy(app.debug)
     for source in app.sources:
         endpoint_bits = re_endpoint_sep.split(source.data_endpoint)
         endpoint = data
@@ -119,8 +123,6 @@
             endpoint = endpoint[e]
         user_data = endpoint.get(endpoint_bits[-1])
         provider = source.buildDataProvider(page, user_data)
-        if endpoint_bits[-1] in endpoint:
-            provider.user_data = endpoint[endpoint_bits[-1]]
         endpoint[endpoint_bits[-1]] = provider
     return data
 
--- a/piecrust/data/paginator.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/data/paginator.py	Sun May 03 18:47:10 2015 -0700
@@ -23,12 +23,11 @@
             'total_item_count', 'total_page_count',
             'next_item', 'prev_item']
 
-    def __init__(self, page, source, page_num=1, pgn_filter=None,
-                 items_per_page=-1):
-        self._parent_page = page
+    def __init__(self, qualified_page, source, *,
+                 page_num=1, pgn_filter=None, items_per_page=-1):
+        self._parent_page = qualified_page
         self._source = source
         self._page_num = page_num
-        self._route = None
         self._iterator = None
         self._pgn_filter = pgn_filter
         self._items_per_page = items_per_page
@@ -214,17 +213,7 @@
         return f
 
     def _getPageUri(self, index):
-        if self._route is None:
-            app = self._source.app
-            self._route = app.getRoute(self._parent_page.source.name,
-                                       self._parent_page.source_metadata)
-            if self._route is None:
-                raise Exception("Can't get route for page: %s" %
-                                self._parent_page.path)
-
-        return self._route.getUri(self._parent_page.source_metadata,
-                                  provider=self._parent_page,
-                                  sub_num=index)
+        return self._parent_page.getUri(index)
 
     def _onIteration(self):
         if self._parent_page is not None and not self._pgn_set_on_ctx:
--- a/piecrust/data/provider.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/data/provider.py	Sun May 03 18:47:10 2015 -0700
@@ -34,25 +34,20 @@
         return []
 
 
-class CompositeDataProvider(object):
-    def __init__(self, providers):
-        self._providers = providers
-
-    def __getattr__(self, name):
-        for p in self._providers:
-            try:
-                return getattr(p, name)
-            except AttributeError:
-                pass
-        raise AttributeError()
-
-
 class IteratorDataProvider(DataProvider):
     PROVIDER_NAME = 'iterator'
 
     debug_render_doc = """Provides a list of pages."""
 
     def __init__(self, source, page, user_data):
+        self._innerIt = None
+        if isinstance(user_data, IteratorDataProvider):
+            # Iterator providers can be chained, like for instance with
+            # `site.pages` listing both the theme pages and the user site's
+            # pages.
+            self._innerIt = user_data
+            user_data = None
+
         super(IteratorDataProvider, self).__init__(source, page, user_data)
         self._pages = PageIterator(source, current_page=page)
         self._pages._iter_event += self._onIteration
@@ -65,7 +60,9 @@
         return self._pages[key]
 
     def __iter__(self):
-        return iter(self._pages)
+        yield from iter(self._pages)
+        if self._innerIt:
+            yield from self._innerIt
 
     def _onIteration(self):
         if not self._ctx_set:
--- a/piecrust/rendering.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/rendering.py	Sun May 03 18:47:10 2015 -0700
@@ -1,6 +1,7 @@
 import re
 import os.path
 import logging
+from werkzeug.utils import cached_property
 from piecrust.data.builder import (DataBuildingContext, build_page_data,
         build_layout_data)
 from piecrust.data.filters import (
@@ -25,6 +26,20 @@
     pass
 
 
+class QualifiedPage(object):
+    def __init__(self, page, route, route_metadata):
+        self.page = page
+        self.route = route
+        self.route_metadata = route_metadata
+
+    def getUri(self, sub_num=1):
+        return self.route.getUri(self.route_metadata, provider=self.page,
+                                 sub_num=sub_num)
+
+    def __getattr__(self, name):
+        return getattr(self.page, name)
+
+
 class RenderedPage(object):
     def __init__(self, page, uri, num=1):
         self.page = page
@@ -53,9 +68,8 @@
 
 
 class PageRenderingContext(object):
-    def __init__(self, page, uri, page_num=1, force_render=False):
-        self.page = page
-        self.uri = uri
+    def __init__(self, qualified_page, page_num=1, force_render=False):
+        self.page = qualified_page
         self.page_num = page_num
         self.force_render = force_render
         self.pagination_source = None
@@ -75,6 +89,10 @@
     def source_metadata(self):
         return self.page.source_metadata
 
+    @cached_property
+    def uri(self):
+        return self.page.getUri(self.page_num)
+
     @property
     def current_pass_info(self):
         return self.render_passes.get(self._current_pass)
@@ -129,7 +147,7 @@
         page = ctx.page
 
         # Build the data for both segment and layout rendering.
-        data_ctx = DataBuildingContext(page, ctx.uri, ctx.page_num)
+        data_ctx = DataBuildingContext(page, page_num=ctx.page_num)
         data_ctx.pagination_source = ctx.pagination_source
         data_ctx.pagination_filter = ctx.pagination_filter
         page_data = build_page_data(data_ctx)
@@ -156,8 +174,8 @@
             layout_name = page.source.config.get('default_layout', 'default')
         null_names = ['', 'none', 'nil']
         if layout_name not in null_names:
-            layout_data = build_layout_data(page, page_data, contents)
-            output = render_layout(layout_name, page, layout_data)
+            build_layout_data(page, page_data, contents)
+            output = render_layout(layout_name, page, page_data)
         else:
             output = contents['content']
 
@@ -173,8 +191,9 @@
 def render_page_segments(ctx):
     repo = ctx.app.env.rendered_segments_repository
     if repo:
-        cache_key = '%s:%s' % (ctx.uri, ctx.page_num)
-        return repo.get(cache_key,
+        cache_key = ctx.uri
+        return repo.get(
+            cache_key,
             lambda: _do_render_page_segments_from_ctx(ctx),
             fs_cache_time=ctx.page.path_mtime)
 
@@ -186,7 +205,7 @@
     eis.pushPage(ctx.page, ctx)
     ctx.setCurrentPass(PASS_FORMATTING)
     try:
-        data_ctx = DataBuildingContext(ctx.page, ctx.uri, ctx.page_num)
+        data_ctx = DataBuildingContext(ctx.page, page_num=ctx.page_num)
         page_data = build_page_data(data_ctx)
         return _do_render_page_segments(ctx.page, page_data)
     finally:
--- a/piecrust/routing.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/routing.py	Sun May 03 18:47:10 2015 -0700
@@ -1,5 +1,6 @@
 import re
 import os.path
+import copy
 import logging
 
 
@@ -65,9 +66,9 @@
         else:
             self.uri_re_no_path = None
 
-        self.required_source_metadata = set()
+        self.required_route_metadata = set()
         for m in route_re.finditer(self.uri_pattern):
-            self.required_source_metadata.add(m.group('name'))
+            self.required_route_metadata.add(m.group('name'))
 
         self.template_func = None
         self.template_func_name = None
@@ -86,8 +87,8 @@
     def source_realm(self):
         return self.source.realm
 
-    def matchesMetadata(self, source_metadata):
-        return self.required_source_metadata.issubset(source_metadata.keys())
+    def matchesMetadata(self, route_metadata):
+        return self.required_route_metadata.issubset(route_metadata.keys())
 
     def matchUri(self, uri):
         if not uri.startswith(self.uri_root):
@@ -108,17 +109,17 @@
                 return m.groupdict()
         return None
 
-    def getUri(self, source_metadata, *, sub_num=1, provider=None):
+    def getUri(self, route_metadata, *, sub_num=1, provider=None):
+        route_metadata = copy.deepcopy(route_metadata)
         if provider:
-            source_metadata = dict(source_metadata)
-            source_metadata.update(provider.getRouteMetadata())
+            route_metadata.update(provider.getRouteMetadata())
 
         #TODO: fix this hard-coded shit
         for key in ['year', 'month', 'day']:
-            if key in source_metadata and isinstance(source_metadata[key], str):
-                source_metadata[key] = int(source_metadata[key])
+            if key in route_metadata and isinstance(route_metadata[key], str):
+                route_metadata[key] = int(route_metadata[key])
 
-        uri = self.uri_format % source_metadata
+        uri = self.uri_format % route_metadata
         suffix = None
         if sub_num > 1:
             # Note that we know the pagination suffix starts with a slash.
--- a/piecrust/serving.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/serving.py	Sun May 03 18:47:10 2015 -0700
@@ -18,7 +18,7 @@
 from piecrust.app import PieCrust
 from piecrust.environment import StandardEnvironment
 from piecrust.processing.base import ProcessorPipeline
-from piecrust.rendering import PageRenderingContext, render_page
+from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
 from piecrust.sources.base import PageFactory, MODE_PARSING
 from piecrust.uriutil import split_sub_uri
 
@@ -252,7 +252,9 @@
         page = factory.buildPage()
         # We force the rendering of the page because it could not have
         # changed, but include pages that did change.
-        render_ctx = PageRenderingContext(page, req_path, page_num,
+        qp = QualifiedPage(page, route, route_metadata)
+        render_ctx = PageRenderingContext(qp,
+                                          page_num=page_num,
                                           force_render=True)
         if taxonomy is not None:
             render_ctx.setTaxonomyFilter(taxonomy, tax_terms)
--- a/piecrust/sources/base.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/sources/base.py	Sun May 03 18:47:10 2015 -0700
@@ -1,3 +1,4 @@
+import copy
 import logging
 from werkzeug.utils import cached_property
 from piecrust.configuration import ConfigurationError
@@ -57,7 +58,7 @@
 
     def _doBuildPage(self):
         logger.debug("Building page: %s" % self.path)
-        page = Page(self.source, self.metadata, self.rel_path)
+        page = Page(self.source, copy.deepcopy(self.metadata), self.rel_path)
         # Load it right away, especially when using the page repository,
         # because we'll be inside a critical scope.
         page._load()
--- a/piecrust/sources/mixins.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/sources/mixins.py	Sun May 03 18:47:10 2015 -0700
@@ -5,6 +5,7 @@
 from piecrust.data.filters import PaginationFilter, page_value_accessor
 from piecrust.sources.base import PageFactory
 from piecrust.sources.interfaces import IPaginationSource, IListableSource
+from piecrust.sources.pageref import PageNotFoundError
 
 
 logger = logging.getLogger(__name__)
@@ -22,6 +23,33 @@
         return self.source.getPages()
 
 
+class SourceFactoryWithoutTaxonomiesIterator(object):
+    def __init__(self, source):
+        self.source = source
+        self._taxonomy_pages = None
+        # See comment above.
+        self.it = None
+
+    def __iter__(self):
+        self._cacheTaxonomyPages()
+        for p in self.source.getPages():
+            if p.rel_path in self._taxonomy_pages:
+                continue
+            yield p
+
+    def _cacheTaxonomyPages(self):
+        if self._taxonomy_pages is not None:
+            return
+
+        self._taxonomy_pages = set()
+        for tax in self.source.app.taxonomies:
+            page_ref = tax.getPageRef(self.source.name)
+            try:
+                self._taxonomy_pages.add(page_ref.rel_path)
+            except PageNotFoundError:
+                pass
+
+
 class DateSortIterator(object):
     def __init__(self, it, reverse=True):
         self.it = it
@@ -52,7 +80,9 @@
         return self.config['items_per_page']
 
     def getSourceIterator(self):
-        return SourceFactoryIterator(self)
+        if self.config.get('iteration_includes_taxonomies', False):
+            return SourceFactoryIterator(self)
+        return SourceFactoryWithoutTaxonomiesIterator(self)
 
     def getSorterIterator(self, it):
         return DateSortIterator(it)
--- a/piecrust/sources/pageref.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/sources/pageref.py	Sun May 03 18:47:10 2015 -0700
@@ -1,5 +1,6 @@
 import re
 import os.path
+import copy
 from piecrust.sources.base import PageFactory
 
 
@@ -74,7 +75,8 @@
         return [h.path for h in self._hits]
 
     def getFactory(self):
-        return PageFactory(self.source, self.rel_path, self.metadata)
+        return PageFactory(self.source, self.rel_path,
+                           copy.deepcopy(self.metadata))
 
     @property
     def _first_valid_hit(self):
@@ -95,15 +97,14 @@
             if source is None:
                 raise Exception("No such source: %s" % source_name)
             rel_path = m.group('path')
-            path, metadata = source.resolveRef(rel_path)
             if '%ext%' in rel_path:
                 for e in self._exts:
+                    cur_rel_path = rel_path.replace('%ext%', e)
+                    path, metadata = source.resolveRef(cur_rel_path)
                     self._hits.append(self._HitInfo(
-                            source_name,
-                            rel_path.replace('%ext%', e),
-                            path.replace('%ext%', e),
-                            metadata))
+                            source_name, cur_rel_path, path, metadata))
             else:
+                path, metadata = source.resolveRef(rel_path)
                 self._hits.append(
                         self._HitInfo(source_name, rel_path, path, metadata))
 
--- a/piecrust/sources/prose.py	Sun May 03 18:43:28 2015 -0700
+++ b/piecrust/sources/prose.py	Sun May 03 18:47:10 2015 -0700
@@ -1,7 +1,8 @@
 import os
 import os.path
+import copy
 import logging
-from piecrust.sources.base import MODE_CREATING
+from piecrust.sources.base import MODE_CREATING, MODE_PARSING
 from piecrust.sources.default import DefaultPageSource
 
 
@@ -19,10 +20,14 @@
         metadata['config'] = self._makeConfig(rel_path, mode)
 
     def _makeConfig(self, rel_path, mode):
-        c = dict(self.config_recipe)
+        c = copy.deepcopy(self.config_recipe)
         if c.get('title') == '%first_line%' and mode != MODE_CREATING:
             path = os.path.join(self.fs_endpoint_path, rel_path)
-            c['title'] = get_first_line(path)
+            try:
+                c['title'] = get_first_line(path)
+            except OSError:
+                if mode == MODE_PARSING:
+                    raise
         return c
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/bakes/test_data_provider.bake	Sun May 03 18:47:10 2015 -0700
@@ -0,0 +1,16 @@
+---
+in:
+    pages/foo.md: |
+        Foo!
+    pages/bar.md: |
+        Bar!
+    pages/allpages.md: |
+        {% for p in site.pages -%}
+        {{p.url}}
+        {% endfor %}
+outfiles:
+    allpages.html: |
+        /
+        /allpages.html
+        /bar.html
+        /foo.html
--- a/tests/bakes/test_pagination.bake	Sun May 03 18:43:28 2015 -0700
+++ b/tests/bakes/test_pagination.bake	Sun May 03 18:47:10 2015 -0700
@@ -39,3 +39,52 @@
         /foo/page2.html
         /foo/page3.html
         None
+---
+config:
+    site:
+        posts_per_page: 3
+in:
+    posts/2015-03-01_post01.md: |
+        ---
+        title: Post 01
+        tags: [foo]
+        ---
+    posts/2015-03-02_post02.md: |
+        ---
+        title: Post 02
+        tags: [foo]
+        ---
+    posts/2015-03-03_post03.md: |
+        ---
+        title: Post 03
+        tags: [foo]
+        ---
+    posts/2015-03-04_post04.md: |
+        ---
+        title: Post 04
+        tags: [foo]
+        ---
+    posts/2015-03-05_post05.md: |
+        ---
+        title: Post 05
+        tags: [foo]
+        ---
+    pages/_index.md: ''
+    pages/_tag.md: |
+        Posts with {{tag}}
+        {% for p in pagination.items -%}
+        {{p.url}} {{p.title}}
+        {% endfor -%}
+        {{pagination.prev_page}}
+        {{pagination.this_page}}
+        {{pagination.next_page}}
+outfiles:
+    tag/foo.html: |
+        Posts with foo
+        /2015/03/05/post05.html Post 05
+        /2015/03/04/post04.html Post 04
+        /2015/03/03/post03.html Post 03
+        None
+        /tag/foo.html
+        /tag/foo/2.html
+
--- a/tests/bakes/test_simple.bake	Sun May 03 18:43:28 2015 -0700
+++ b/tests/bakes/test_simple.bake	Sun May 03 18:47:10 2015 -0700
@@ -25,4 +25,11 @@
                 post1.html: 'post one'
     about.html: 'URL: /whatever/about.html'
     index.html: 'something'
+---
+in:
+    pages/foo.md: |
+        This page is {{page.url}}
+outfiles:
+    foo.html: |
+        This page is /foo.html
 
--- a/tests/mockutil.py	Sun May 03 18:43:28 2015 -0700
+++ b/tests/mockutil.py	Sun May 03 18:47:10 2015 -0700
@@ -8,7 +8,7 @@
 import yaml
 from piecrust.app import PieCrust, PieCrustConfiguration
 from piecrust.page import Page
-from piecrust.rendering import PageRenderingContext, render_page
+from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
 
 
 resources_path = os.path.abspath(
@@ -25,12 +25,13 @@
 
 def get_simple_page(app, rel_path):
     source = app.getSource('pages')
-    metadata = {'path': os.path.splitext(rel_path)[0]}
+    metadata = {'slug': os.path.splitext(rel_path)[0]}
     return Page(source, metadata, rel_path)
 
 
-def render_simple_page(page, uri):
-    ctx = PageRenderingContext(page, uri)
+def render_simple_page(page, route, route_metadata):
+    qp = QualifiedPage(page, route, route_metadata)
+    ctx = PageRenderingContext(qp)
     rp = render_page(ctx)
     return rp.content
 
--- a/tests/test_data_paginator.py	Sun May 03 18:43:28 2015 -0700
+++ b/tests/test_data_paginator.py	Sun May 03 18:47:10 2015 -0700
@@ -1,4 +1,5 @@
 import math
+import mock
 import pytest
 from piecrust.data.paginator import Paginator
 from piecrust.sources.interfaces import IPaginationSource
@@ -44,17 +45,17 @@
         ('blog', 3, 14)
     ])
 def test_paginator(uri, page_num, count):
-    def _mock_get_uri(index):
+    def _get_mock_uri(sub_num):
         res = uri
-        if index > 1:
+        if sub_num > 1:
             if res != '' and not res.endswith('/'):
                 res += '/'
-            res += '%d' % index
+            res += '%d' % sub_num
         return res
 
     source = MockSource(count)
-    p = Paginator(None, source, page_num)
-    p._getPageUri = _mock_get_uri
+    p = Paginator(None, source, page_num=page_num)
+    p._getPageUri = _get_mock_uri
 
     if count <= 5:
         # All posts fit on the page
--- a/tests/test_data_provider.py	Sun May 03 18:43:28 2015 -0700
+++ b/tests/test_data_provider.py	Sun May 03 18:47:10 2015 -0700
@@ -1,4 +1,4 @@
-from piecrust.rendering import PageRenderingContext, render_page
+from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
 from .mockutil import mock_fs, mock_fs_scope
 
 
@@ -18,7 +18,10 @@
     with mock_fs_scope(fs):
         app = fs.getApp()
         page = app.getSource('pages').getPage({'slug': 'categories'})
-        ctx = PageRenderingContext(page, '/categories')
+        route = app.getRoute('pages', None)
+        route_metadata = {'slug': 'categories'}
+        qp = QualifiedPage(page, route, route_metadata)
+        ctx = PageRenderingContext(qp)
         rp = render_page(ctx)
         expected = "\nBar (1)\n\nFoo (2)\n"
         assert rp.content == expected
--- a/tests/test_routing.py	Sun May 03 18:43:28 2015 -0700
+++ b/tests/test_routing.py	Sun May 03 18:47:10 2015 -0700
@@ -42,7 +42,7 @@
     app.config.set('site/root', site_root.rstrip('/') + '/')
     config = {'url': route_pattern, 'source': 'blah'}
     route = Route(app, config)
-    assert route.required_source_metadata == expected_required_metadata
+    assert route.required_route_metadata == expected_required_metadata
 
 
 @pytest.mark.parametrize(
--- a/tests/test_serving.py	Sun May 03 18:43:28 2015 -0700
+++ b/tests/test_serving.py	Sun May 03 18:47:10 2015 -0700
@@ -4,7 +4,7 @@
 from piecrust.data.filters import (
         PaginationFilter, HasFilterClause, IsFilterClause,
         page_value_accessor)
-from piecrust.rendering import PageRenderingContext, render_page
+from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
 from piecrust.serving import find_routes
 from piecrust.sources.base import REALM_USER, REALM_THEME
 from .mockutil import mock_fs, mock_fs_scope
@@ -74,10 +74,13 @@
                     "{%endfor%}"))
     with mock_fs_scope(fs):
         app = fs.getApp()
-        page = app.getSource('pages').getPage({'slug': '_tag'})
+        page = app.getSource('pages').getPage({'slug': '_tag', 'tag': tag})
+        route = app.getTaxonomyRoute('tags', 'posts')
+        route_metadata = {'slug': '_tag', 'tag': tag}
         taxonomy = app.getTaxonomy('tags')
 
-        ctx = PageRenderingContext(page, '/tag/' + tag)
+        qp = QualifiedPage(page, route, route_metadata)
+        ctx = PageRenderingContext(qp)
         ctx.setTaxonomyFilter(taxonomy, tag)
         rp = render_page(ctx)
 
@@ -115,10 +118,14 @@
                     "{%endfor%}"))
     with mock_fs_scope(fs):
         app = fs.getApp()
-        page = app.getSource('pages').getPage({'slug': '_category'})
+        page = app.getSource('pages').getPage({'slug': '_category',
+                                               'category': category})
+        route = app.getTaxonomyRoute('categories', 'posts')
+        route_metadata = {'slug': '_category', 'category': category}
         taxonomy = app.getTaxonomy('categories')
 
-        ctx = PageRenderingContext(page, '/' + category)
+        qp = QualifiedPage(page, route, route_metadata)
+        ctx = PageRenderingContext(qp)
         ctx.setTaxonomyFilter(taxonomy, category)
         rp = render_page(ctx)
 
--- a/tests/test_templating_jinjaengine.py	Sun May 03 18:43:28 2015 -0700
+++ b/tests/test_templating_jinjaengine.py	Sun May 03 18:47:10 2015 -0700
@@ -19,7 +19,7 @@
             ("Raw text", "Raw text"),
             ("This is {{foo}}", "This is bar"),
             ("Info:\nMy URL: {{page.url}}\n",
-                "Info:\nMy URL: /foo")
+                "Info:\nMy URL: /foo.html")
             ])
 def test_simple(contents, expected):
     fs = (mock_fs()
@@ -28,7 +28,9 @@
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
         page = get_simple_page(app, 'foo.md')
-        output = render_simple_page(page, '/foo')
+        route = app.getRoute('pages', None)
+        route_metadata = {'slug': 'foo'}
+        output = render_simple_page(page, route, route_metadata)
         assert output == expected
 
 
@@ -44,14 +46,16 @@
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
         page = get_simple_page(app, 'foo.md')
-        output = render_simple_page(page, '/foo')
+        route = app.getRoute('pages', None)
+        route_metadata = {'slug': 'foo'}
+        output = render_simple_page(page, route, route_metadata)
         assert output == expected
 
 
 def test_partial():
     contents = "Info:\n{% include 'page_info.jinja' %}\n"
     partial = "- URL: {{page.url}}\n- SLUG: {{page.slug}}\n"
-    expected = "Info:\n- URL: /foo\n- SLUG: foo"
+    expected = "Info:\n- URL: /foo.html\n- SLUG: foo"
     fs = (mock_fs()
             .withConfig(app_config)
             .withAsset('templates/page_info.jinja', partial)
@@ -59,6 +63,8 @@
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
         page = get_simple_page(app, 'foo.md')
-        output = render_simple_page(page, '/foo')
+        route = app.getRoute('pages', None)
+        route_metadata = {'slug': 'foo'}
+        output = render_simple_page(page, route, route_metadata)
         assert output == expected
 
--- a/tests/test_templating_pystacheengine.py	Sun May 03 18:43:28 2015 -0700
+++ b/tests/test_templating_pystacheengine.py	Sun May 03 18:47:10 2015 -0700
@@ -19,7 +19,7 @@
             ("Raw text", "Raw text"),
             ("This is {{foo}}", "This is bar"),
             ("Info:\n{{#page}}\nMy URL: {{url}}\n{{/page}}\n",
-                "Info:\nMy URL: /foo\n")
+                "Info:\nMy URL: /foo.html\n")
             ])
 def test_simple(contents, expected):
     fs = (mock_fs()
@@ -28,7 +28,9 @@
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
         page = get_simple_page(app, 'foo.md')
-        output = render_simple_page(page, '/foo')
+        route = app.getRoute('pages', None)
+        route_metadata = {'slug': 'foo'}
+        output = render_simple_page(page, route, route_metadata)
         assert output == expected
 
 
@@ -44,14 +46,16 @@
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
         page = get_simple_page(app, 'foo.md')
-        output = render_simple_page(page, '/foo')
+        route = app.getRoute('pages', None)
+        route_metadata = {'slug': 'foo'}
+        output = render_simple_page(page, route, route_metadata)
         assert output == expected
 
 
 def test_partial():
     contents = "Info:\n{{#page}}\n{{> page_info}}\n{{/page}}\n"
     partial = "- URL: {{url}}\n- SLUG: {{slug}}\n"
-    expected = "Info:\n- URL: /foo\n- SLUG: foo\n"
+    expected = "Info:\n- URL: /foo.html\n- SLUG: foo\n"
     fs = (mock_fs()
             .withConfig(app_config)
             .withAsset('templates/page_info.mustache', partial)
@@ -59,6 +63,8 @@
     with mock_fs_scope(fs, open_patches=open_patches):
         app = fs.getApp()
         page = get_simple_page(app, 'foo.md')
-        output = render_simple_page(page, '/foo')
+        route = app.getRoute('pages', None)
+        route_metadata = {'slug': 'foo'}
+        output = render_simple_page(page, route, route_metadata)
         assert output == expected