changeset 334:b034f6f15e22

bake: Several bug taxonomy-related fixes for incorrect incremental bakes. * Improve how the baker processes taxonomy terms and figures out what needs to be re-baked or not. * Create bake entries for clean taxnomy terms so they're not deleted by an incremental bake. * Add more information to bake records. * Slugify taxonomy terms is now done by the route in one place. * Fix a bug where the cache key for invalidating rendered segments was not computed the same way as when the caching was done. * Fix how term combinations are passed around, rendered, printed, parsed, etc. (TODO: more word needed in the routing functions) * Expose to the template whether a taxonomy term is a combination or not. * Display term combinations better in the built-in theme. * Rename `route.taxonomy` to `route.taxonomy_name` to prevent confusion. * Add options to show bake records for previous bakes.
author Ludovic Chabant <ludovic@chabant.com>
date Fri, 03 Apr 2015 10:59:50 -0700
parents 91b07f9efdc1
children 8511137d1b62
files piecrust/app.py piecrust/baking/baker.py piecrust/baking/records.py piecrust/baking/single.py piecrust/commands/builtin/baking.py piecrust/commands/builtin/info.py piecrust/rendering.py piecrust/resources/theme/pages/_tag.html piecrust/routing.py piecrust/serving.py
diffstat 10 files changed, 237 insertions(+), 128 deletions(-) [+]
line wrap: on
line diff
--- a/piecrust/app.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/app.py	Fri Apr 03 10:59:50 2015 -0700
@@ -531,7 +531,7 @@
     def getRoutes(self, source_name, skip_taxonomies=False):
         for route in self.routes:
             if route.source_name == source_name:
-                if not skip_taxonomies or route.taxonomy is None:
+                if not skip_taxonomies or route.taxonomy_name is None:
                     yield route
 
     def getRoute(self, source_name, source_metadata):
@@ -542,7 +542,7 @@
 
     def getTaxonomyRoute(self, tax_name, source_name):
         for route in self.routes:
-            if route.taxonomy == tax_name and route.source_name == source_name:
+            if route.taxonomy_name == tax_name and route.source_name == source_name:
                 return route
         return None
 
--- a/piecrust/baking/baker.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/baking/baker.py	Fri Apr 03 10:59:50 2015 -0700
@@ -175,7 +175,8 @@
                             (source.name, fac.ref_spec))
                     continue
 
-                entry = BakeRecordPageEntry(fac)
+                entry = BakeRecordPageEntry(fac.source.name, fac.rel_path,
+                                            fac.path)
                 record.addEntry(entry)
 
                 route = self.app.getRoute(source.name, fac.metadata)
@@ -193,8 +194,22 @@
     def _bakeTaxonomies(self, record):
         logger.debug("Baking taxonomies")
 
+        class _TaxonomyTermsInfo(object):
+            def __init__(self):
+                self.dirty_terms = set()
+                self.all_terms = set()
+
+            def __str__(self):
+                return 'dirty:%s, all:%s' % (self.dirty_terms, self.all_terms)
+
+            def __repr__(self):
+                return 'dirty:%s, all:%s' % (self.dirty_terms, self.all_terms)
+
         # Let's see all the taxonomy terms for which we must bake a
         # listing page... first, pre-populate our big map of used terms.
+        # For each source name, we have a list of taxonomies, and for each
+        # taxonomies, a list of terms, some being 'dirty', some used last
+        # time, etc.
         buckets = {}
         tax_names = [t.name for t in self.app.taxonomies]
         source_names = [s.name for s in self.app.sources]
@@ -202,56 +217,78 @@
             source_taxonomies = {}
             buckets[sn] = source_taxonomies
             for tn in tax_names:
-                source_taxonomies[tn] = set()
+                source_taxonomies[tn] = _TaxonomyTermsInfo()
 
         # Now see which ones are 'dirty' based on our bake record.
         logger.debug("Gathering dirty taxonomy terms")
         for prev_entry, cur_entry in record.transitions.values():
             for tax in self.app.taxonomies:
-                changed_terms = None
                 # Re-bake all taxonomy pages that include new or changed
                 # pages.
-                if (not prev_entry and cur_entry and
-                        cur_entry.was_baked_successfully):
-                    changed_terms = cur_entry.config.get(tax.setting_name)
-                elif (prev_entry and cur_entry and
-                        cur_entry.was_baked_successfully):
-                    changed_terms = []
-                    prev_terms = prev_entry.config.get(tax.setting_name)
+                if cur_entry and cur_entry.was_baked_successfully:
+                    if prev_entry and prev_entry.was_baked_successfully:
+                        # Entry was re-baked this time. Mark as dirty both the
+                        # old and new terms.
+                        changed_terms = []
+                        prev_terms = prev_entry.config.get(tax.setting_name)
+                        cur_terms = cur_entry.config.get(tax.setting_name)
+                        if tax.is_multiple:
+                            if prev_terms is not None:
+                                changed_terms += prev_terms
+                            if cur_terms is not None:
+                                changed_terms += cur_terms
+                        else:
+                            if prev_terms is not None:
+                                changed_terms.append(prev_terms)
+                            if cur_terms is not None:
+                                changed_terms.append(cur_terms)
+                    else:
+                        # Entry was not baked last time. Just mark as dirty
+                        # all the new terms.
+                        changed_terms = cur_entry.config.get(tax.setting_name)
+
+                    if changed_terms is not None:
+                        if not isinstance(changed_terms, list):
+                            changed_terms = [changed_terms]
+                        tt_info = buckets[cur_entry.source_name][tax.name]
+                        tt_info.dirty_terms |= set(changed_terms)
+
+                # Remember all terms used.
+                if cur_entry and cur_entry.was_baked_successfully:
                     cur_terms = cur_entry.config.get(tax.setting_name)
-                    if tax.is_multiple:
-                        if prev_terms is not None:
-                            changed_terms += prev_terms
-                        if cur_terms is not None:
-                            changed_terms += cur_terms
-                    else:
-                        if prev_terms is not None:
-                            changed_terms.append(prev_terms)
-                        if cur_terms is not None:
-                            changed_terms.append(cur_terms)
-                if changed_terms is not None:
-                    if not isinstance(changed_terms, list):
-                        changed_terms = [changed_terms]
-                    buckets[cur_entry.source_name][tax.name] |= (
-                            set(changed_terms))
+                    if cur_terms is not None:
+                        if not isinstance(cur_terms, list):
+                            cur_terms = [cur_terms]
+                        tt_info = buckets[cur_entry.source_name][tax.name]
+                        tt_info.all_terms |= set(cur_terms)
+                elif (prev_entry and prev_entry.was_baked_successfully and
+                        cur_entry and not cur_entry.was_baked):
+                    prev_terms = prev_entry.config.get(tax.setting_name)
+                    if prev_terms is not None:
+                        if not isinstance(prev_terms, list):
+                            prev_terms = [prev_terms]
+                        tt_info = buckets[prev_entry.source_name][tax.name]
+                        tt_info.all_terms |= set(prev_terms)
 
         # Re-bake the combination pages for terms that are 'dirty'.
         known_combinations = set()
         logger.debug("Gathering dirty term combinations")
         for prev_entry, cur_entry in record.transitions.values():
-            if cur_entry:
+            if cur_entry and cur_entry.was_baked_successfully:
                 known_combinations |= cur_entry.used_taxonomy_terms
             elif prev_entry:
                 known_combinations |= prev_entry.used_taxonomy_terms
         for sn, tn, terms in known_combinations:
-            changed_terms = buckets[sn][tn]
-            if not changed_terms.isdisjoint(set(terms)):
-                changed_terms.add(terms)
+            tt_info = buckets[sn][tn]
+            tt_info.all_terms.add(terms)
+            if not tt_info.dirty_terms.isdisjoint(set(terms)):
+                tt_info.dirty_terms.add(terms)
 
         # Start baking those terms.
         pool, queue, abort = self._createWorkerPool(record, self.num_workers)
         for source_name, source_taxonomies in buckets.items():
-            for tax_name, terms in source_taxonomies.items():
+            for tax_name, tt_info in source_taxonomies.items():
+                terms = tt_info.dirty_terms
                 if len(terms) == 0:
                     continue
 
@@ -280,14 +317,38 @@
                     logger.debug(
                             "Queuing: %s [%s, %s]" %
                             (fac.ref_spec, tax_name, term))
-                    entry = BakeRecordPageEntry(fac, tax_name, term)
+                    entry = BakeRecordPageEntry(
+                            fac.source.name, fac.rel_path, fac.path,
+                            (tax_name, term, source_name))
                     record.addEntry(entry)
-                    queue.addJob(
-                            BakeWorkerJob(fac, route, entry, tax_name, term))
+                    queue.addJob(BakeWorkerJob(fac, route, entry))
 
         success = self._waitOnWorkerPool(pool, abort)
         record.current.success &= success
 
+        # Now we create bake entries for all the terms that were *not* dirty.
+        # This is because otherwise, on the next incremental bake, we wouldn't
+        # find any entry for those things, and figure that we need to delete
+        # their outputs.
+        for prev_entry, cur_entry in record.transitions.values():
+            # Only consider taxonomy-related entries that don't have any
+            # current version.
+            if (prev_entry and prev_entry.taxonomy_info and
+                    not cur_entry):
+                sn = prev_entry.source_name
+                tn, tt, tsn = prev_entry.taxonomy_info
+                tt_info = buckets[tsn][tn]
+                if tt in tt_info.all_terms:
+                    logger.debug("Creating unbaked entry for taxonomy "
+                                 "term '%s:%s'." % (tn, tt))
+                    entry = BakeRecordPageEntry(
+                            prev_entry.source_name, prev_entry.rel_path,
+                            prev_entry.path, prev_entry.taxonomy_info)
+                    record.addEntry(entry)
+                else:
+                    logger.debug("Taxonomy term '%s:%s' isn't used anymore." %
+                                 (tn, tt))
+
     def _handleDeletetions(self, record):
         for path, reason in record.getDeletions():
             logger.debug("Removing '%s': %s" % (path, reason))
@@ -355,13 +416,10 @@
 
 
 class BakeWorkerJob(object):
-    def __init__(self, factory, route, record_entry,
-                 taxonomy_name=None, taxonomy_term=None):
+    def __init__(self, factory, route, record_entry):
         self.factory = factory
         self.route = route
         self.record_entry = record_entry
-        self.taxonomy_name = taxonomy_name
-        self.taxonomy_term = taxonomy_term
 
     @property
     def source(self):
@@ -406,10 +464,7 @@
 
         entry = job.record_entry
         try:
-            self._page_baker.bake(
-                    job.factory, job.route, entry,
-                    taxonomy_name=job.taxonomy_name,
-                    taxonomy_term=job.taxonomy_term)
+            self._page_baker.bake(job.factory, job.route, entry)
         except BakingError as ex:
             logger.debug("Got baking error. Adding it to the record.")
             while ex:
--- a/piecrust/baking/records.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/baking/records.py	Fri Apr 03 10:59:50 2015 -0700
@@ -1,17 +1,16 @@
 import os.path
 import logging
-from piecrust.sources.base import PageSource
 from piecrust.records import Record, TransitionalRecord
 
 
 logger = logging.getLogger(__name__)
 
 
-def _get_transition_key(source_name, rel_path, taxonomy_name=None,
-        taxonomy_term=None):
+def _get_transition_key(source_name, rel_path, taxonomy_info=None):
     key = '%s:%s' % (source_name, rel_path)
-    if taxonomy_name and taxonomy_term:
-        key += ';%s:' % taxonomy_name
+    if taxonomy_info:
+        taxonomy_name, taxonomy_term, taxonomy_source_name = taxonomy_info
+        key += ';%s:%s=' % (taxonomy_source_name, taxonomy_name)
         if isinstance(taxonomy_term, tuple):
             key += '/'.join(taxonomy_term)
         else:
@@ -20,7 +19,7 @@
 
 
 class BakeRecord(Record):
-    RECORD_VERSION = 9
+    RECORD_VERSION = 11
 
     def __init__(self):
         super(BakeRecord, self).__init__()
@@ -32,24 +31,35 @@
 FLAG_NONE = 0
 FLAG_SOURCE_MODIFIED = 2**0
 FLAG_OVERRIDEN = 2**1
+FLAG_FORCED_BY_SOURCE = 2**2
 
 
 class BakeRecordPageEntry(object):
-    def __init__(self, factory, taxonomy_name=None, taxonomy_term=None):
-        self.path = factory.path
-        self.rel_path = factory.rel_path
-        self.source_name = factory.source.name
-        self.taxonomy_name = taxonomy_name
-        self.taxonomy_term = taxonomy_term
-        self.path_mtime = os.path.getmtime(factory.path)
+    """ An entry in the bake record.
+
+        The `taxonomy_info` attribute should be a tuple of the form:
+        (taxonomy name, term, source name)
+    """
+    def __init__(self, source_name, rel_path, path, taxonomy_info=None):
+        self.source_name = source_name
+        self.rel_path = rel_path
+        self.path = path
+        self.taxonomy_info = taxonomy_info
 
         self.flags = FLAG_NONE
         self.config = None
         self.errors = []
         self.out_uris = []
         self.out_paths = []
+        self.clean_uris = []
+        self.clean_out_paths = []
         self.used_source_names = set()
         self.used_taxonomy_terms = set()
+        self.used_pagination_item_count = 0
+
+    @property
+    def path_mtime(self):
+        return os.path.getmtime(self.path)
 
     @property
     def was_baked(self):
@@ -63,11 +73,6 @@
     def num_subs(self):
         return len(self.out_paths)
 
-    def __getstate__(self):
-        state = self.__dict__.copy()
-        del state['path_mtime']
-        return state
-
 
 class TransitionalBakeRecord(TransitionalRecord):
     def __init__(self, previous_path=None):
@@ -82,7 +87,7 @@
 
     def getTransitionKey(self, entry):
         return _get_transition_key(entry.source_name, entry.rel_path,
-                                   entry.taxonomy_name, entry.taxonomy_term)
+                                   entry.taxonomy_info)
 
     def getOverrideEntry(self, factory, uri):
         for pair in self.transitions.values():
@@ -100,10 +105,8 @@
                 return prev
         return None
 
-    def getPreviousEntry(self, source_name, rel_path, taxonomy_name=None,
-            taxonomy_term=None):
-        key = _get_transition_key(source_name, rel_path,
-                taxonomy_name, taxonomy_term)
+    def getPreviousEntry(self, source_name, rel_path, taxonomy_info=None):
+        key = _get_transition_key(source_name, rel_path, taxonomy_info)
         pair = self.transitions.get(key)
         if pair is not None:
             return pair[0]
--- a/piecrust/baking/single.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/baking/single.py	Fri Apr 03 10:59:50 2015 -0700
@@ -3,7 +3,8 @@
 import codecs
 import logging
 import urllib.parse
-from piecrust.baking.records import FLAG_OVERRIDEN, FLAG_SOURCE_MODIFIED
+from piecrust.baking.records import (
+        FLAG_OVERRIDEN, FLAG_SOURCE_MODIFIED, FLAG_FORCED_BY_SOURCE)
 from piecrust.data.filters import (PaginationFilter, HasFilterClause,
         IsFilterClause, AndBooleanClause,
         page_value_accessor)
@@ -56,21 +57,15 @@
 
         return os.path.normpath(os.path.join(*bake_path))
 
-    def bake(self, factory, route, record_entry,
-             taxonomy_name=None, taxonomy_term=None):
-        taxonomy = None
+    def bake(self, factory, route, record_entry):
+        bake_taxonomy_info = None
         route_metadata = dict(factory.metadata)
-        if taxonomy_name and taxonomy_term:
-            # TODO: add options for combining and slugifying terms
-            taxonomy = self.app.getTaxonomy(taxonomy_name)
-            if taxonomy.is_multiple:
-                if isinstance(taxonomy_term, tuple):
-                    slugified_term = '/'.join(taxonomy_term)
-                else:
-                    slugified_term = taxonomy_term
-            else:
-                slugified_term = taxonomy_term
-            route_metadata.update({taxonomy.setting_name: slugified_term})
+        if record_entry.taxonomy_info:
+            tax_name, tax_term, tax_source_name = record_entry.taxonomy_info
+            taxonomy = self.app.getTaxonomy(tax_name)
+            slugified_term = route.slugifyTaxonomyTerm(tax_term)
+            route_metadata[taxonomy.term_name] = slugified_term
+            bake_taxonomy_info = (taxonomy, tax_term)
 
         # Generate the URL using the route.
         page = factory.buildPage()
@@ -83,10 +78,11 @@
                 raise BakingError(
                         "Page '%s' maps to URL '%s' but is overriden by page"
                         "'%s:%s'." % (factory.ref_spec, uri,
-                            override.source_name, override.rel_path))
+                                      override.source_name,
+                                      override.rel_path))
             logger.debug("'%s' [%s] is overriden by '%s:%s'. Skipping" %
-                    (factory.ref_spec, uri, override.source_name,
-                        override.rel_path))
+                         (factory.ref_spec, uri, override.source_name,
+                          override.rel_path))
             record_entry.flags |= FLAG_OVERRIDEN
             return
 
@@ -97,7 +93,7 @@
         record_entry.config = copy_public_page_config(page.config)
         prev_record_entry = self.record.getPreviousEntry(
                 factory.source.name, factory.rel_path,
-                taxonomy_name, taxonomy_term)
+                record_entry.taxonomy_info)
 
         logger.debug("Baking '%s'..." % uri)
 
@@ -118,7 +114,9 @@
                 logger.debug("'%s' is known to use sources %s, at least one "
                              "of which got baked. Will force bake this page. "
                              % (uri, used_src_names))
+                record_entry.flags |= FLAG_FORCED_BY_SOURCE
                 force_this = True
+
                 if PASS_FORMATTING in invalidated_render_passes:
                     logger.debug("Will invalidate cached formatting for '%s' "
                                  "since sources were using during that pass."
@@ -134,7 +132,7 @@
             do_bake = True
             if not force_this:
                 try:
-                    in_path_time = record_entry.path_mtime
+                    in_path_time = page.path_mtime
                     out_path_time = os.path.getmtime(out_path)
                     if out_path_time >= in_path_time:
                         do_bake = False
@@ -151,7 +149,9 @@
                     cur_sub += 1
                     has_more_subs = True
                     logger.debug("  %s is up to date, skipping to next "
-                            "sub-page." % out_path)
+                                 "sub-page." % out_path)
+                    record_entry.clean_uris.append(sub_uri)
+                    record_entry.clean_out_paths.append(out_path)
                     continue
 
                 # We don't know how many subs to expect... just skip.
@@ -161,19 +161,19 @@
             # All good, proceed.
             try:
                 if invalidate_formatting:
-                    cache_key = '%s:%s' % (uri, cur_sub)
+                    cache_key = sub_uri
                     self.app.env.rendered_segments_repository.invalidate(
                             cache_key)
 
                 logger.debug("  p%d -> %s" % (cur_sub, out_path))
                 ctx, rp = self._bakeSingle(page, sub_uri, cur_sub, out_path,
-                                           taxonomy, taxonomy_term)
+                                           bake_taxonomy_info)
             except Exception as ex:
                 if self.app.debug:
                     logger.exception(ex)
                 page_rel_path = os.path.relpath(page.path, self.app.root_dir)
                 raise BakingError("%s: error baking '%s'." %
-                        (page_rel_path, uri)) from ex
+                                  (page_rel_path, uri)) from ex
 
             # Copy page assets.
             if (cur_sub == 1 and self.copy_assets and
@@ -201,17 +201,20 @@
             record_entry.used_taxonomy_terms |= ctx.used_taxonomy_terms
 
             has_more_subs = False
-            if (ctx.used_pagination is not None and
-                    ctx.used_pagination.has_more):
-                cur_sub += 1
-                has_more_subs = True
+            if ctx.used_pagination is not None:
+                if cur_sub == 1:
+                    record_entry.used_pagination_item_count = \
+                            ctx.used_pagination.total_item_count
+                if ctx.used_pagination.has_more:
+                    cur_sub += 1
+                    has_more_subs = True
 
     def _bakeSingle(self, page, sub_uri, num, out_path,
-                    taxonomy=None, taxonomy_term=None):
+                    taxonomy_info=None):
         ctx = PageRenderingContext(page, sub_uri)
         ctx.page_num = num
-        if taxonomy and taxonomy_term:
-            ctx.setTaxonomyFilter(taxonomy, taxonomy_term)
+        if taxonomy_info:
+            ctx.setTaxonomyFilter(taxonomy_info[0], taxonomy_info[1])
 
         rp = render_page(ctx)
 
--- a/piecrust/commands/builtin/baking.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/commands/builtin/baking.py	Fri Apr 03 10:59:50 2015 -0700
@@ -5,7 +5,11 @@
 import fnmatch
 import datetime
 from piecrust.baking.baker import Baker
-from piecrust.baking.records import BakeRecord
+from piecrust.baking.records import (
+        BakeRecord,
+        FLAG_OVERRIDEN as BAKE_FLAG_OVERRIDEN,
+        FLAG_SOURCE_MODIFIED as BAKE_FLAG_SOURCE_MODIFIED,
+        FLAG_FORCED_BY_SOURCE as BAKE_FLAG_FORCED_BY_SOURCE)
 from piecrust.chefutil import format_timed
 from piecrust.commands.base import ChefCommand
 from piecrust.processing.base import ProcessorPipeline
@@ -13,6 +17,7 @@
         ProcessorPipelineRecord,
         FLAG_PREPARED, FLAG_PROCESSED, FLAG_OVERRIDEN,
         FLAG_BYPASSED_STRUCTURED_PROCESSING)
+from piecrust.rendering import PASS_FORMATTING, PASS_RENDERING
 
 
 logger = logging.getLogger(__name__)
@@ -104,11 +109,17 @@
                 '-t', '--out',
                 help="A pattern that will be used to filter the output path "
                      "of entries to show.")
+        parser.add_argument(
+                '--last',
+                type=int,
+                default=0,
+                help="Show the last Nth bake record.")
 
     def run(self, ctx):
         out_dir = ctx.args.output or os.path.join(ctx.app.root_dir, '_counter')
-        record_name = (hashlib.md5(out_dir.encode('utf8')).hexdigest() +
-                       '.record')
+        record_id = hashlib.md5(out_dir.encode('utf8')).hexdigest()
+        suffix = '' if ctx.args.last == 0 else '.%d' % ctx.args.last
+        record_name = '%s%s.record' % (record_id, suffix)
 
         pattern = None
         if ctx.args.path:
@@ -126,6 +137,7 @@
         # Show the bake record.
         record = BakeRecord.load(record_cache.getCachePath(record_name))
         logging.info("Bake record for: %s" % record.out_dir)
+        logging.info("From: %s" % record_name)
         logging.info("Last baked: %s" %
                      datetime.datetime.fromtimestamp(record.bake_time))
         if record.success:
@@ -141,17 +153,36 @@
                          if fnmatch.fnmatch(o, out_pattern)])):
                 continue
 
+            flags = []
+            if entry.flags & BAKE_FLAG_OVERRIDEN:
+                flags.append('overriden')
+            if entry.flags & BAKE_FLAG_SOURCE_MODIFIED:
+                flags.append('overriden')
+            if entry.flags & BAKE_FLAG_FORCED_BY_SOURCE:
+                flags.append('forced by source')
+
+            passes = {PASS_RENDERING: 'render', PASS_FORMATTING: 'format'}
+            used_srcs = ['%s (%s)' % (s[0], passes[s[1]])
+                         for s in entry.used_source_names]
+
             logging.info(" - ")
             logging.info("   path:      %s" % entry.rel_path)
             logging.info("   spec:      %s:%s" % (entry.source_name,
                                                   entry.rel_path))
-            logging.info("   taxonomy:  %s:%s" % (entry.taxonomy_name,
-                                                  entry.taxonomy_term))
+            if entry.taxonomy_info:
+                logging.info("   taxonomy:  %s:%s for %s" %
+                             entry.taxonomy_info)
+            else:
+                logging.info("   taxonomy:  <none>")
+            logging.info("   flags:     %s" % ', '.join(flags))
             logging.info("   config:    %s" % entry.config)
             logging.info("   out URLs:  %s" % entry.out_uris)
             logging.info("   out paths: %s" % [os.path.relpath(p, out_dir)
                                                for p in entry.out_paths])
-            logging.info("   used srcs: %s" % entry.used_source_names)
+            logging.info("   clean URLs:%s" % entry.clean_uris)
+            logging.info("   used srcs: %s" % used_srcs)
+            logging.info("   used terms:%s" % entry.used_taxonomy_terms)
+            logging.info("   used pgn:  %d" % entry.used_pagination_item_count)
             if entry.errors:
                 logging.error("   errors: %s" % entry.errors)
 
--- a/piecrust/commands/builtin/info.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/commands/builtin/info.py	Fri Apr 03 10:59:50 2015 -0700
@@ -78,7 +78,7 @@
         for route in ctx.app.routes:
             logger.info("%s:" % route.uri_pattern)
             logger.info("    source: %s" % route.source_name)
-            logger.info("    taxonomy: %s" % (route.taxonomy or ''))
+            logger.info("    taxonomy: %s" % (route.taxonomy_name or ''))
             logger.info("    regex: %s" % route.uri_re.pattern)
 
 
--- a/piecrust/rendering.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/rendering.py	Fri Apr 03 10:59:50 2015 -0700
@@ -68,9 +68,6 @@
     def source_metadata(self):
         return self.page.source_metadata
 
-    def reset(self):
-        self.used_pagination = None
-
     def setPagination(self, paginator):
         if self.used_pagination is not None:
             raise Exception("Pagination has already been used.")
@@ -82,9 +79,10 @@
             self.used_source_names.add((source.name, self.current_pass))
 
     def setTaxonomyFilter(self, taxonomy, term_value):
+        is_combination = isinstance(term_value, tuple)
         flt = PaginationFilter(value_accessor=page_value_accessor)
         if taxonomy.is_multiple:
-            if isinstance(term_value, tuple):
+            if is_combination:
                 abc = AndBooleanClause()
                 for t in term_value:
                     abc.addClause(HasFilterClause(taxonomy.setting_name, t))
@@ -95,8 +93,10 @@
         else:
             flt.addClause(IsFilterClause(taxonomy.setting_name, term_value))
         self.pagination_filter = flt
+
         self.custom_data = {
-                taxonomy.term_name: term_value}
+                taxonomy.term_name: term_value,
+                'is_multiple_%s' % taxonomy.term_name: is_combination}
 
 
 def render_page(ctx):
@@ -117,7 +117,7 @@
         ctx.current_pass = PASS_FORMATTING
         repo = ctx.app.env.rendered_segments_repository
         if repo and not ctx.force_render:
-            cache_key = '%s:%s' % (ctx.uri, ctx.page_num)
+            cache_key = ctx.uri
             page_time = page.path_mtime
             contents = repo.get(
                     cache_key,
--- a/piecrust/resources/theme/pages/_tag.html	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/resources/theme/pages/_tag.html	Fri Apr 03 10:59:50 2015 -0700
@@ -2,7 +2,11 @@
 title:
 format: none
 ---
-<h2>Posts tagged with {{ tag }}</h2>
+{% set display_tag = tag %}
+{% if is_multiple_tag %}
+    {% set display_tag = tag|join(', ') %}
+{% endif %}
+<h2>Posts tagged with {{ display_tag }}</h2>
 
 <section>
     {% for post in pagination.posts %}
--- a/piecrust/routing.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/routing.py	Fri Apr 03 10:59:50 2015 -0700
@@ -27,6 +27,9 @@
     def __init__(self, app, cfg):
         self.app = app
 
+        self.source_name = cfg['source']
+        self.taxonomy_name = cfg.get('taxonomy')
+
         self.pretty_urls = app.config.get('site/pretty_urls')
         self.trailing_slash = app.config.get('site/trailing_slash')
         self.pagination_suffix_format = app.config.get(
@@ -66,8 +69,6 @@
         for m in route_re.finditer(self.uri_pattern):
             self.required_source_metadata.add(m.group('name'))
 
-        self.source_name = cfg['source']
-        self.taxonomy = cfg.get('taxonomy')
         self.template_func = None
         self.template_func_name = None
         self.template_func_args = []
@@ -157,6 +158,19 @@
         uri = self.uri_root + uri
         return uri
 
+    def slugifyTaxonomyTerm(self, term):
+        #TODO: add options for transliterating and combining terms.
+        if isinstance(term, tuple):
+            return '/'.join(term)
+        return term
+
+    def unslugifyTaxonomyTerm(self, term):
+        #TODO: same as above.
+        split_terms = term.split('/')
+        if len(split_terms) == 1:
+            return term
+        return tuple(split_terms)
+
     def _uriFormatRepl(self, m):
         name = m.group('name')
         #TODO: fix this hard-coded shit
@@ -171,7 +185,7 @@
     def _uriPatternRepl(self, m):
         name = m.group('name')
         qualifier = m.group('qual')
-        if qualifier == 'path':
+        if qualifier == 'path' or self.taxonomy_name:
             return r'(?P<%s>[^\?]*)' % name
         return r'(?P<%s>[^/\?]+)' % name
 
@@ -198,7 +212,7 @@
         if arg_list:
             self.template_func_args += template_func_arg_re.findall(arg_list)
 
-        if self.taxonomy:
+        if self.taxonomy_name:
             # This will be a taxonomy route function... this means we can
             # have a variable number of parameters, but only one parameter
             # definition, which is the value.
@@ -226,12 +240,10 @@
                     registered_values = tuple(values)
                 eis = self.app.env.exec_info_stack
                 eis.current_page_info.render_ctx.used_taxonomy_terms.add(
-                        (self.source_name, self.taxonomy, registered_values))
+                        (self.source_name, self.taxonomy_name,
+                            registered_values))
 
-                if len(values) == 1:
-                    str_values = values[0]
-                else:
-                    str_values = '/'.join(values)
+                str_values = self.slugifyTaxonomyTerm(registered_values)
                 term_name = self.template_func_args[0]
                 metadata = {term_name: str_values}
 
@@ -270,7 +282,7 @@
         self._funcs.append((route, route.template_func))
 
     def __call__(self, *args, **kwargs):
-        if len(args) == len(self._arg_names):
+        if len(self._funcs) == 1 or len(args) == len(self._arg_names):
             f = self._funcs[0][1]
             return f(*args, **kwargs)
 
--- a/piecrust/serving.py	Fri Apr 03 08:44:21 2015 -0700
+++ b/piecrust/serving.py	Fri Apr 03 10:59:50 2015 -0700
@@ -227,22 +227,23 @@
             raise RouteNotFoundError("Can't find route for: %s" % req_path)
 
         taxonomy = None
-        term_value = None
+        tax_terms = None
         for route, route_metadata in routes:
             source = app.getSource(route.source_name)
-            if route.taxonomy is None:
+            if route.taxonomy_name is None:
                 rel_path, fac_metadata = source.findPagePath(
                         route_metadata, MODE_PARSING)
                 if rel_path is not None:
                     break
             else:
-                taxonomy = app.getTaxonomy(route.taxonomy)
-                term_value = route_metadata.get(taxonomy.term_name)
-                if term_value is not None:
+                taxonomy = app.getTaxonomy(route.taxonomy_name)
+                route_terms = route_metadata.get(taxonomy.term_name)
+                if route_terms is not None:
                     tax_page_ref = taxonomy.getPageRef(source.name)
                     rel_path = tax_page_ref.rel_path
                     source = tax_page_ref.source
-                    fac_metadata = {taxonomy.term_name: term_value}
+                    tax_terms = route.unslugifyTaxonomyTerm(route_terms)
+                    fac_metadata = {taxonomy.term_name: tax_terms}
                     break
         else:
             raise SourceNotFoundError(
@@ -257,7 +258,7 @@
         render_ctx = PageRenderingContext(page, req_path, page_num,
                                           force_render=True)
         if taxonomy is not None:
-            render_ctx.setTaxonomyFilter(taxonomy, term_value)
+            render_ctx.setTaxonomyFilter(taxonomy, tax_terms)
 
         # See if this page is known to use sources. If that's the case,
         # just don't use cached rendered segments for that page (but still