changeset 805:fd694f1297c7

config: Cleanup config loading code. Add support for a `local.yml` config.
author Ludovic Chabant <ludovic@chabant.com>
date Mon, 10 Oct 2016 21:41:59 -0700
parents 08e6484a2600
children 8ac2d6045d1d
files piecrust/app.py piecrust/appconfig.py piecrust/appconfigdefaults.py piecrust/configuration.py piecrust/publishing/base.py tests/mockutil.py tests/test_appconfig.py
diffstat 7 files changed, 490 insertions(+), 385 deletions(-) [+]
line wrap: on
line diff
--- a/piecrust/app.py	Wed Oct 12 21:01:42 2016 -0700
+++ b/piecrust/app.py	Mon Oct 10 21:41:59 2016 -0700
@@ -67,6 +67,10 @@
                 path=path, theme_path=theme_path,
                 cache=config_cache, theme_config=self.theme_site)
 
+        local_path = os.path.join(
+                self.root_dir, 'configs', 'local.yml')
+        config.addVariant(local_path, raise_if_not_found=False)
+
         if self.theme_site:
             variant_path = os.path.join(
                     self.root_dir, 'configs', 'theme_preview.yml')
--- a/piecrust/appconfig.py	Wed Oct 12 21:01:42 2016 -0700
+++ b/piecrust/appconfig.py	Mon Oct 10 21:41:59 2016 -0700
@@ -7,26 +7,34 @@
 import hashlib
 import collections
 import yaml
-from piecrust import (
-        APP_VERSION, CACHE_VERSION,
-        DEFAULT_FORMAT, DEFAULT_TEMPLATE_ENGINE, DEFAULT_POSTS_FS,
-        DEFAULT_DATE_FORMAT, DEFAULT_THEME_SOURCE)
+from piecrust import APP_VERSION, CACHE_VERSION, DEFAULT_DATE_FORMAT
+from piecrust.appconfigdefaults import (
+    default_configuration,
+    default_theme_content_model_base,
+    default_content_model_base,
+    get_default_content_model, get_default_content_model_for_blog)
 from piecrust.cache import NullCache
 from piecrust.configuration import (
-        Configuration, ConfigurationError, ConfigurationLoader,
-        try_get_dict_value, try_get_dict_values,
-        set_dict_value, merge_dicts, visit_dict)
+    Configuration, ConfigurationError, ConfigurationLoader,
+    try_get_dict_values, try_get_dict_value, set_dict_value,
+    merge_dicts, visit_dict,
+    MERGE_NEW_VALUES, MERGE_OVERWRITE_VALUES, MERGE_PREPEND_LISTS,
+    MERGE_APPEND_LISTS)
 from piecrust.sources.base import REALM_USER, REALM_THEME
 
 
 logger = logging.getLogger(__name__)
 
 
+class InvalidConfigurationPathError(Exception):
+    pass
+
+
 class VariantNotFoundError(Exception):
     def __init__(self, variant_name, message=None):
         super(VariantNotFoundError, self).__init__(
-                message or ("No such configuration variant: %s" %
-                            variant_name))
+            message or ("No such configuration variant: %s" %
+                        variant_name))
 
 
 class PieCrustConfiguration(Configuration):
@@ -44,10 +52,12 @@
         self.theme_config = theme_config
         # Set the values after we set the rest, since our validation needs
         # our attributes.
-        if values:
+        if values is not None:
             self.setAll(values, validate=validate)
 
     def addPath(self, p):
+        if not p:
+            raise InvalidConfigurationPathError()
         self._ensureNotLoaded()
         self._custom_paths.append(p)
 
@@ -57,11 +67,10 @@
             self.addPath(variant_path)
         elif raise_if_not_found:
             logger.error(
-                    "Configuration variants should now be `.yml` files "
-                    "located in the `configs/` directory of your website.")
+                "Configuration variants should now be `.yml` files "
+                "located in the `configs/` directory of your website.")
             raise VariantNotFoundError(variant_path)
 
-
     def addVariantValue(self, path, value):
         def _fixup(config):
             set_dict_value(config, path, value)
@@ -70,7 +79,7 @@
 
     def setAll(self, values, validate=False):
         # Override base class implementation
-        values = self._combineConfigs({}, values)
+        values = self._processConfigs({}, values)
         if validate:
             values = self._validateAll(values)
         self._values = values
@@ -81,14 +90,23 @@
 
     def _load(self):
         # Figure out where to load this configuration from.
-        paths = [self._theme_path, self._path] + self._custom_paths
-        paths = list(filter(lambda i: i is not None, paths))
+        paths = []
+        if self._theme_path:
+            paths.append(self._theme_path)
+        if self._path:
+            paths.append(self._path)
+        paths += self._custom_paths
+
+        if len(paths) == 0:
+            raise ConfigurationError(
+                "No paths to load configuration from. "
+                "Specify paths, or set the values directly.")
 
         # Build the cache-key.
         path_times = [os.path.getmtime(p) for p in paths]
         cache_key_hash = hashlib.md5(
-                ("version=%s&cache=%d" % (
-                    APP_VERSION, CACHE_VERSION)).encode('utf8'))
+            ("version=%s&cache=%d" % (
+                APP_VERSION, CACHE_VERSION)).encode('utf8'))
         for p in paths:
             cache_key_hash.update(("&path=%s" % p).encode('utf8'))
         cache_key = cache_key_hash.hexdigest()
@@ -98,8 +116,8 @@
             logger.debug("Loading configuration from cache...")
             config_text = self._cache.read('config.json')
             self._values = json.loads(
-                    config_text,
-                    object_pairs_hook=collections.OrderedDict)
+                config_text,
+                object_pairs_hook=collections.OrderedDict)
 
             actual_cache_key = self._values.get('__cache_key')
             if actual_cache_key == cache_key:
@@ -107,46 +125,36 @@
                 self._values['__cache_valid'] = True
                 return
             logger.debug("Outdated cache key '%s' (expected '%s')." % (
-                    actual_cache_key, cache_key))
+                actual_cache_key, cache_key))
 
         # Nope, load from the paths.
         try:
-            # Theme config.
-            theme_values = {}
+            # Theme values.
+            theme_values = None
             if self._theme_path:
+                logger.debug("Loading theme layer from: %s" % self._theme_path)
                 theme_values = self._loadFrom(self._theme_path)
 
-            # Site config.
-            site_values = {}
+            # Site and variant values.
+            site_paths = []
             if self._path:
-                site_values = self._loadFrom(self._path)
-
-            # Combine!
-            logger.debug("Processing loaded configurations...")
-            values = self._combineConfigs(theme_values, site_values)
+                site_paths.append(self._path)
+            site_paths += self._custom_paths
 
-            # Load additional paths.
-            if self._custom_paths:
-                logger.debug("Loading %d additional configuration paths." %
-                             len(self._custom_paths))
-                for p in self._custom_paths:
-                    loaded = self._loadFrom(p)
-                    if loaded:
-                        merge_dicts(values, loaded)
+            site_values = {}
+            for path in site_paths:
+                logger.debug("Loading config layer from: %s" % path)
+                cur_values = self._loadFrom(path)
+                merge_dicts(site_values, cur_values)
 
-            # Run final fixups
-            if self._post_fixups:
-                logger.debug("Applying %d configuration fixups." %
-                             len(self._post_fixups))
-                for f in self._post_fixups:
-                    f(values)
-
+            # Do it!
+            values = self._processConfigs(theme_values, site_values)
             self._values = self._validateAll(values)
         except Exception as ex:
             logger.exception(ex)
             raise Exception(
-                    "Error loading configuration from: %s" %
-                    ', '.join(paths)) from ex
+                "Error loading configuration from: %s" %
+                ', '.join(paths)) from ex
 
         logger.debug("Caching configuration...")
         self._values['__cache_key'] = cache_key
@@ -159,27 +167,22 @@
         logger.debug("Loading configuration from: %s" % path)
         with open(path, 'r', encoding='utf-8') as fp:
             values = yaml.load(
-                    fp.read(),
-                    Loader=ConfigurationLoader)
+                fp.read(),
+                Loader=ConfigurationLoader)
         if values is None:
             values = {}
         return values
 
-    def _combineConfigs(self, theme_values, site_values):
+    def _processConfigs(self, theme_values, site_values):
         # Start with the default configuration.
         values = copy.deepcopy(default_configuration)
 
-        if not self.theme_config:
-            # If the theme config wants the default model, add it.
-            theme_sitec = theme_values.setdefault(
-                    'site', collections.OrderedDict())
-            gen_default_theme_model = bool(theme_sitec.setdefault(
-                    'use_default_theme_content', True))
-            if gen_default_theme_model:
-                self._generateDefaultThemeModel(values)
-
-            # Now override with the actual theme config values.
-            values = merge_dicts(values, theme_values)
+        # If we have a theme, apply the theme on that. So stuff like routes
+        # will now look like:
+        # [custom theme] + [default theme] + [default]
+        if theme_values is not None:
+            self._processThemeLayer(theme_values, values)
+            merge_dicts(values, theme_values)
 
         # Make all sources belong to the "theme" realm at this point.
         srcc = values['site'].get('sources')
@@ -187,23 +190,71 @@
             for sn, sc in srcc.items():
                 sc['realm'] = REALM_THEME
 
-        # If the site config wants the default model, add it.
-        site_sitec = site_values.setdefault(
-                'site', collections.OrderedDict())
-        gen_default_site_model = bool(site_sitec.setdefault(
-                'use_default_content', True))
-        if gen_default_site_model:
-            self._generateDefaultSiteModel(values, site_values)
-
-        # And override with the actual site config values.
-        values = merge_dicts(values, site_values)
+        # Now we apply the site stuff. We want to end up with:
+        # [custom site] + [default site] + [custom theme] + [default theme] +
+        #   [default]
+        if site_values is not None:
+            self._processSiteLayer(site_values, values)
+            merge_dicts(values, site_values)
 
         # Set the theme site flag.
         if self.theme_config:
             values['site']['theme_site'] = True
 
+        # Run final fixups
+        if self._post_fixups:
+            logger.debug("Applying %d configuration fixups." %
+                         len(self._post_fixups))
+            for f in self._post_fixups:
+                f(values)
+
         return values
 
+    def _processThemeLayer(self, theme_values, values):
+        # Generate the default theme model.
+        gen_default_theme_model = bool(try_get_dict_values(
+            (theme_values, 'site/use_default_theme_content'),
+            (values, 'site/use_default_theme_content'),
+            default=True))
+        if gen_default_theme_model:
+            self._generateDefaultThemeModel(theme_values, values)
+
+    def _processSiteLayer(self, site_values, values):
+        # Default site content.
+        gen_default_site_model = bool(try_get_dict_values(
+            (site_values, 'site/use_default_content'),
+            (values, 'site/use_default_content'),
+            default=True))
+        if gen_default_site_model:
+            self._generateDefaultSiteModel(site_values, values)
+
+    def _generateDefaultThemeModel(self, theme_values, values):
+        logger.debug("Generating default theme content model...")
+        cc = copy.deepcopy(default_theme_content_model_base)
+        merge_dicts(values, cc)
+
+    def _generateDefaultSiteModel(self, site_values, values):
+        logger.debug("Generating default content model...")
+        cc = copy.deepcopy(default_content_model_base)
+        merge_dicts(values, cc)
+
+        dcm = get_default_content_model(site_values, values)
+        merge_dicts(values, dcm)
+
+        blogsc = try_get_dict_values(
+            (site_values, 'site/blogs'),
+            (values, 'site/blogs'))
+        if blogsc is None:
+            blogsc = ['posts']
+            set_dict_value(site_values, 'site/blogs', blogsc)
+
+        is_only_blog = (len(blogsc) == 1)
+        for blog_name in reversed(blogsc):
+            blog_cfg = get_default_content_model_for_blog(
+                blog_name, is_only_blog, site_values, values,
+                theme_site=self.theme_config)
+            merge_dicts(values, blog_cfg)
+
     def _validateAll(self, values):
         if values is None:
             values = {}
@@ -235,31 +286,6 @@
 
         return values
 
-    def _generateDefaultThemeModel(self, values):
-        logger.debug("Generating default theme content model...")
-        cc = copy.deepcopy(default_theme_content_model_base)
-        merge_dicts(values, cc)
-
-    def _generateDefaultSiteModel(self, values, user_overrides):
-        logger.debug("Generating default content model...")
-        cc = copy.deepcopy(default_content_model_base)
-        merge_dicts(values, cc)
-
-        dcm = get_default_content_model(values, user_overrides)
-        merge_dicts(values, dcm)
-
-        blogsc = try_get_dict_value(user_overrides, 'site/blogs')
-        if blogsc is None:
-            blogsc = ['posts']
-            set_dict_value(user_overrides, 'site/blogs', blogsc)
-
-        is_only_blog = (len(blogsc) == 1)
-        for blog_name in blogsc:
-            blog_cfg = get_default_content_model_for_blog(
-                    blog_name, is_only_blog, values, user_overrides,
-                    theme_site=self.theme_config)
-            merge_dicts(values, blog_cfg)
-
 
 class _ConfigCacheWriter(object):
     def __init__(self, cache_dict):
@@ -270,259 +296,6 @@
         self._cache_dict[name] = val
 
 
-default_theme_content_model_base = collections.OrderedDict({
-        'site': collections.OrderedDict({
-            'sources': collections.OrderedDict({
-                'theme_pages': {
-                    'type': 'default',
-                    'ignore_missing_dir': True,
-                    'fs_endpoint': 'pages',
-                    'data_endpoint': 'site.pages',
-                    'default_layout': 'default',
-                    'item_name': 'page',
-                    'realm': REALM_THEME
-                    }
-                }),
-            'routes': [
-                {
-                    'url': '/%slug%',
-                    'source': 'theme_pages',
-                    'func': 'pcurl'
-                    }
-                ],
-            'theme_tag_page': 'theme_pages:_tag.%ext%',
-            'theme_category_page': 'theme_pages:_category.%ext%',
-            'theme_month_page': 'theme_pages:_month.%ext%',
-            'theme_year_page': 'theme_pages:_year.%ext%'
-            })
-        })
-
-
-default_configuration = collections.OrderedDict({
-        'site': collections.OrderedDict({
-            'title': "Untitled PieCrust website",
-            'root': '/',
-            'default_format': DEFAULT_FORMAT,
-            'default_template_engine': DEFAULT_TEMPLATE_ENGINE,
-            'enable_gzip': True,
-            'pretty_urls': False,
-            'trailing_slash': False,
-            'date_format': DEFAULT_DATE_FORMAT,
-            'auto_formats': collections.OrderedDict([
-                ('html', ''),
-                ('md', 'markdown'),
-                ('textile', 'textile')]),
-            'default_auto_format': 'md',
-            'default_pagination_source': None,
-            'pagination_suffix': '/%num%',
-            'slugify_mode': 'encode',
-            'themes_sources': [DEFAULT_THEME_SOURCE],
-            'cache_time': 28800,
-            'enable_debug_info': True,
-            'show_debug_info': False,
-            'use_default_content': True,
-            'use_default_theme_content': True,
-            'theme_site': False
-            }),
-        'baker': collections.OrderedDict({
-            'no_bake_setting': 'draft',
-            'workers': None,
-            'batch_size': None
-            })
-        })
-
-
-default_content_model_base = collections.OrderedDict({
-        'site': collections.OrderedDict({
-            'posts_fs': DEFAULT_POSTS_FS,
-            'default_page_layout': 'default',
-            'default_post_layout': 'post',
-            'post_url': '/%year%/%month%/%day%/%slug%',
-            'year_url': '/archives/%year%',
-            'tag_url': '/tag/%tag%',
-            'category_url': '/%category%',
-            'posts_per_page': 5
-            })
-        })
-
-
-def get_default_content_model(values, user_overrides):
-    default_layout = try_get_dict_value(
-            user_overrides, 'site/default_page_layout',
-            values['site']['default_page_layout'])
-    return collections.OrderedDict({
-            'site': collections.OrderedDict({
-                'sources': collections.OrderedDict({
-                    'pages': {
-                        'type': 'default',
-                        'ignore_missing_dir': True,
-                        'data_endpoint': 'site.pages',
-                        'default_layout': default_layout,
-                        'item_name': 'page'
-                        }
-                    }),
-                'routes': [
-                    {
-                        'url': '/%slug%',
-                        'source': 'pages',
-                        'func': 'pcurl'
-                        }
-                    ],
-                'taxonomies': collections.OrderedDict([
-                    ('tags', {
-                        'multiple': True,
-                        'term': 'tag'
-                        }),
-                    ('categories', {
-                        'term': 'category',
-                        'func_name': 'pccaturl'
-                        })
-                    ])
-                })
-            })
-
-
-def get_default_content_model_for_blog(
-        blog_name, is_only_blog, values, user_overrides, theme_site=False):
-    # Get the global (default) values for various things we're interested in.
-    defs = {}
-    names = ['posts_fs', 'posts_per_page', 'date_format',
-             'default_post_layout', 'post_url', 'year_url']
-    for n in names:
-        defs[n] = try_get_dict_value(
-                user_overrides, 'site/%s' % n,
-                values['site'][n])
-
-    # More stuff we need.
-    if is_only_blog:
-        url_prefix = ''
-        page_prefix = ''
-        fs_endpoint = 'posts'
-        data_endpoint = 'blog'
-        item_name = 'post'
-        tpl_func_prefix = 'pc'
-
-        if theme_site:
-            # If this is a theme site, show posts from a `sample` directory
-            # so it's clearer that those won't show up when the theme is
-            # actually applied to a normal site.
-            fs_endpoint = 'sample/posts'
-    else:
-        url_prefix = blog_name + '/'
-        page_prefix = blog_name + '/'
-        data_endpoint = blog_name
-        fs_endpoint = 'posts/%s' % blog_name
-        item_name = try_get_dict_value(user_overrides,
-                                       '%s/item_name' % blog_name,
-                                       '%spost' % blog_name)
-        tpl_func_prefix = try_get_dict_value(user_overrides,
-                                             '%s/func_prefix' % blog_name,
-                                             'pc%s' % blog_name)
-
-    # Figure out the settings values for this blog, specifically.
-    # The value could be set on the blog config itself, globally, or left at
-    # its default. We already handle the "globally vs. default" with the
-    # `defs` map that we computed above.
-    blog_cfg = user_overrides.get(blog_name, {})
-    blog_values = {}
-    for n in names:
-        blog_values[n] = blog_cfg.get(n, defs[n])
-
-    posts_fs = blog_values['posts_fs']
-    posts_per_page = blog_values['posts_per_page']
-    date_format = blog_values['date_format']
-    default_layout = blog_values['default_post_layout']
-    post_url = '/' + url_prefix + blog_values['post_url'].lstrip('/')
-    year_url = '/' + url_prefix + blog_values['year_url'].lstrip('/')
-
-    year_archive = 'pages:%s_year.%%ext%%' % page_prefix
-    if not theme_site:
-        theme_year_page = values['site'].get('theme_year_page')
-        if theme_year_page:
-            year_archive += ';' + theme_year_page
-
-    cfg = collections.OrderedDict({
-            'site': collections.OrderedDict({
-                'sources': collections.OrderedDict({
-                    blog_name: collections.OrderedDict({
-                        'type': 'posts/%s' % posts_fs,
-                        'fs_endpoint': fs_endpoint,
-                        'data_endpoint': data_endpoint,
-                        'item_name': item_name,
-                        'ignore_missing_dir': True,
-                        'data_type': 'blog',
-                        'items_per_page': posts_per_page,
-                        'date_format': date_format,
-                        'default_layout': default_layout
-                        })
-                    }),
-                'generators': collections.OrderedDict({
-                    ('%s_archives' % blog_name): collections.OrderedDict({
-                        'type': 'blog_archives',
-                        'source': blog_name,
-                        'page': year_archive
-                        })
-                    }),
-                'routes': [
-                    {
-                        'url': post_url,
-                        'source': blog_name,
-                        'func': ('%sposturl' % tpl_func_prefix)
-                        },
-                    {
-                        'url': year_url,
-                        'generator': ('%s_archives' % blog_name),
-                        'func': ('%syearurl' % tpl_func_prefix)
-                        }
-                    ]
-                })
-            })
-
-    # Add a generator and a route for each taxonomy.
-    taxonomies_cfg = values.get('site', {}).get('taxonomies', {}).copy()
-    taxonomies_cfg.update(
-            user_overrides.get('site', {}).get('taxonomies', {}))
-    for tax_name, tax_cfg in taxonomies_cfg.items():
-        term = tax_cfg.get('term', tax_name)
-
-        # Generator.
-        page_ref = 'pages:%s_%s.%%ext%%' % (page_prefix, term)
-        if not theme_site:
-            theme_page_ref = values['site'].get('theme_%s_page' % term)
-            if theme_page_ref:
-                page_ref += ';' + theme_page_ref
-        tax_gen_name = '%s_%s' % (blog_name, tax_name)
-        tax_gen = collections.OrderedDict({
-            'type': 'taxonomy',
-            'source': blog_name,
-            'taxonomy': tax_name,
-            'page': page_ref
-            })
-        cfg['site']['generators'][tax_gen_name] = tax_gen
-
-        # Route.
-        tax_url_cfg_name = '%s_url' % term
-        tax_url = try_get_dict_values(
-                (blog_cfg, tax_url_cfg_name),
-                (user_overrides, 'site/%s' % tax_url_cfg_name),
-                (values, 'site/%s' % tax_url_cfg_name),
-                default=('%s/%%%s%%' % (term, term)))
-        tax_url = '/' + url_prefix + tax_url.lstrip('/')
-        tax_func_name = try_get_dict_values(
-                (user_overrides, 'site/taxonomies/%s/func_name' % tax_name),
-                (values, 'site/taxonomies/%s/func_name' % tax_name),
-                default=('%s%surl' % (tpl_func_prefix, term)))
-        tax_route = collections.OrderedDict({
-            'url': tax_url,
-            'generator': tax_gen_name,
-            'taxonomy': tax_name,
-            'func': tax_func_name
-            })
-        cfg['site']['routes'].append(tax_route)
-
-    return cfg
-
-
 # Configuration value validators.
 #
 # Make sure we have basic site stuff.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/appconfigdefaults.py	Mon Oct 10 21:41:59 2016 -0700
@@ -0,0 +1,269 @@
+import collections
+from piecrust import (
+    DEFAULT_FORMAT, DEFAULT_TEMPLATE_ENGINE, DEFAULT_POSTS_FS,
+    DEFAULT_DATE_FORMAT, DEFAULT_THEME_SOURCE)
+from piecrust.configuration import (
+    get_dict_values, try_get_dict_values)
+from piecrust.sources.base import REALM_THEME
+
+
+default_configuration = collections.OrderedDict({
+    'site': collections.OrderedDict({
+        'title': "Untitled PieCrust website",
+        'root': '/',
+        'default_format': DEFAULT_FORMAT,
+        'default_template_engine': DEFAULT_TEMPLATE_ENGINE,
+        'enable_gzip': True,
+        'pretty_urls': False,
+        'trailing_slash': False,
+        'date_format': DEFAULT_DATE_FORMAT,
+        'auto_formats': collections.OrderedDict([
+            ('html', ''),
+            ('md', 'markdown'),
+            ('textile', 'textile')]),
+        'default_auto_format': 'md',
+        'default_pagination_source': None,
+        'pagination_suffix': '/%num%',
+        'slugify_mode': 'encode',
+        'themes_sources': [DEFAULT_THEME_SOURCE],
+        'cache_time': 28800,
+        'enable_debug_info': True,
+        'show_debug_info': False,
+        'use_default_content': True,
+        'use_default_theme_content': True,
+        'theme_site': False
+    }),
+    'baker': collections.OrderedDict({
+        'no_bake_setting': 'draft',
+        'workers': None,
+        'batch_size': None
+    })
+})
+
+
+default_theme_content_model_base = collections.OrderedDict({
+    'site': collections.OrderedDict({
+        'sources': collections.OrderedDict({
+            'theme_pages': {
+                'type': 'default',
+                'ignore_missing_dir': True,
+                'fs_endpoint': 'pages',
+                'data_endpoint': 'site.pages',
+                'default_layout': 'default',
+                'item_name': 'page',
+                'realm': REALM_THEME
+            }
+        }),
+        'routes': [
+            {
+                'url': '/%slug%',
+                'source': 'theme_pages',
+                'func': 'pcurl'
+            }
+        ],
+        'theme_tag_page': 'theme_pages:_tag.%ext%',
+        'theme_category_page': 'theme_pages:_category.%ext%',
+        'theme_month_page': 'theme_pages:_month.%ext%',
+        'theme_year_page': 'theme_pages:_year.%ext%'
+    })
+})
+
+
+default_content_model_base = collections.OrderedDict({
+    'site': collections.OrderedDict({
+        'posts_fs': DEFAULT_POSTS_FS,
+        'default_page_layout': 'default',
+        'default_post_layout': 'post',
+        'post_url': '/%year%/%month%/%day%/%slug%',
+        'year_url': '/archives/%year%',
+        'tag_url': '/tag/%tag%',
+        'category_url': '/%category%',
+        'posts_per_page': 5
+    })
+})
+
+
+def get_default_content_model(site_values, values):
+    default_layout = get_dict_values(
+        (site_values, 'site/default_page_layout'),
+        (values, 'site/default_page_layout'))
+    return collections.OrderedDict({
+        'site': collections.OrderedDict({
+            'sources': collections.OrderedDict({
+                'pages': {
+                    'type': 'default',
+                    'ignore_missing_dir': True,
+                    'data_endpoint': 'site.pages',
+                    'default_layout': default_layout,
+                    'item_name': 'page'
+                }
+            }),
+            'routes': [
+                {
+                    'url': '/%slug%',
+                    'source': 'pages',
+                    'func': 'pcurl'
+                }
+            ],
+            'taxonomies': collections.OrderedDict([
+                ('tags', {
+                    'multiple': True,
+                    'term': 'tag'
+                }),
+                ('categories', {
+                    'term': 'category',
+                    'func_name': 'pccaturl'
+                })
+            ])
+        })
+    })
+
+
+def get_default_content_model_for_blog(blog_name, is_only_blog,
+                                       site_values, values,
+                                       theme_site=False):
+    # Get the global (default) values for various things we're interested in.
+    defs = {}
+    names = ['posts_fs', 'posts_per_page', 'date_format',
+             'default_post_layout', 'post_url', 'year_url']
+    for n in names:
+        defs[n] = get_dict_values(
+            (site_values, 'site/%s' % n),
+            (values, 'site/%s' % n))
+
+    # More stuff we need.
+    if is_only_blog:
+        url_prefix = ''
+        page_prefix = ''
+        fs_endpoint = 'posts'
+        data_endpoint = 'blog'
+        item_name = 'post'
+        tpl_func_prefix = 'pc'
+
+        if theme_site:
+            # If this is a theme site, show posts from a `sample` directory
+            # so it's clearer that those won't show up when the theme is
+            # actually applied to a normal site.
+            fs_endpoint = 'sample/posts'
+    else:
+        url_prefix = blog_name + '/'
+        page_prefix = blog_name + '/'
+        data_endpoint = blog_name
+        fs_endpoint = 'posts/%s' % blog_name
+        item_name = try_get_dict_values(
+            (site_values, '%s/item_name' % blog_name),
+            (values, '%s/item_name' % blog_name),
+            default=('%spost' % blog_name))
+        tpl_func_prefix = try_get_dict_values(
+            (site_values, '%s/func_prefix' % blog_name),
+            (values, '%s/func_prefix' % blog_name),
+            default=('pc%s' % blog_name))
+
+    # Figure out the settings values for this blog, specifically.
+    # The value could be set on the blog config itself, globally, or left at
+    # its default. We already handle the "globally vs. default" with the
+    # `defs` map that we computed above.
+    blog_cfg = values.get(blog_name, {})
+    blog_values = {}
+    for n in names:
+        blog_values[n] = blog_cfg.get(n, defs[n])
+
+    posts_fs = blog_values['posts_fs']
+    posts_per_page = blog_values['posts_per_page']
+    date_format = blog_values['date_format']
+    default_layout = blog_values['default_post_layout']
+    post_url = '/' + url_prefix + blog_values['post_url'].lstrip('/')
+    year_url = '/' + url_prefix + blog_values['year_url'].lstrip('/')
+
+    year_archive = 'pages:%s_year.%%ext%%' % page_prefix
+    if not theme_site:
+        theme_year_page = try_get_dict_values(
+            (site_values, 'site/theme_year_page'),
+            (values, 'site/theme_year_page'))
+        if theme_year_page:
+            year_archive += ';' + theme_year_page
+
+    cfg = collections.OrderedDict({
+        'site': collections.OrderedDict({
+            'sources': collections.OrderedDict({
+                blog_name: collections.OrderedDict({
+                    'type': 'posts/%s' % posts_fs,
+                    'fs_endpoint': fs_endpoint,
+                    'data_endpoint': data_endpoint,
+                    'item_name': item_name,
+                    'ignore_missing_dir': True,
+                    'data_type': 'blog',
+                    'items_per_page': posts_per_page,
+                    'date_format': date_format,
+                    'default_layout': default_layout
+                })
+            }),
+            'generators': collections.OrderedDict({
+                ('%s_archives' % blog_name): collections.OrderedDict({
+                    'type': 'blog_archives',
+                    'source': blog_name,
+                    'page': year_archive
+                })
+            }),
+            'routes': [
+                {
+                    'url': post_url,
+                    'source': blog_name,
+                    'func': ('%sposturl' % tpl_func_prefix)
+                },
+                {
+                    'url': year_url,
+                    'generator': ('%s_archives' % blog_name),
+                    'func': ('%syearurl' % tpl_func_prefix)
+                }
+            ]
+        })
+    })
+
+    # Add a generator and a route for each taxonomy.
+    taxonomies_cfg = try_get_dict_values(
+        (site_values, 'site/taxonomies'),
+        (values, 'site/taxonomies'),
+        default={}).copy()
+    for tax_name, tax_cfg in taxonomies_cfg.items():
+        term = tax_cfg.get('term', tax_name)
+
+        # Generator.
+        page_ref = 'pages:%s_%s.%%ext%%' % (page_prefix, term)
+        if not theme_site:
+            theme_page_ref = try_get_dict_values(
+                (site_values, 'site/theme_%s_page' % term),
+                (values, 'site/theme_%s_page' % term))
+            if theme_page_ref:
+                page_ref += ';' + theme_page_ref
+        tax_gen_name = '%s_%s' % (blog_name, tax_name)
+        tax_gen = collections.OrderedDict({
+            'type': 'taxonomy',
+            'source': blog_name,
+            'taxonomy': tax_name,
+            'page': page_ref
+        })
+        cfg['site']['generators'][tax_gen_name] = tax_gen
+
+        # Route.
+        tax_url_cfg_name = '%s_url' % term
+        tax_url = try_get_dict_values(
+            (blog_cfg, tax_url_cfg_name),
+            (site_values, 'site/%s' % tax_url_cfg_name),
+            (values, 'site/%s' % tax_url_cfg_name),
+            default=('%s/%%%s%%' % (term, term)))
+        tax_url = '/' + url_prefix + tax_url.lstrip('/')
+        tax_func_name = try_get_dict_values(
+            (site_values, 'site/taxonomies/%s/func_name' % tax_name),
+            (values, 'site/taxonomies/%s/func_name' % tax_name),
+            default=('%s%surl' % (tpl_func_prefix, term)))
+        tax_route = collections.OrderedDict({
+            'url': tax_url,
+            'generator': tax_gen_name,
+            'taxonomy': tax_name,
+            'func': tax_func_name
+        })
+        cfg['site']['routes'].append(tax_route)
+
+    return cfg
+
--- a/piecrust/configuration.py	Wed Oct 12 21:01:42 2016 -0700
+++ b/piecrust/configuration.py	Mon Oct 10 21:41:59 2016 -0700
@@ -124,7 +124,16 @@
     return cur
 
 
-def try_get_dict_value(d, key, default=None):
+def get_dict_values(*args):
+    for d, key in args:
+        try:
+            return get_dict_value(d, key)
+        except KeyError:
+            continue
+    raise KeyError()
+
+
+def try_get_dict_value(d, key, *, default=None):
     try:
         return get_dict_value(d, key)
     except KeyError:
@@ -153,14 +162,23 @@
             cur = cur[b]
 
 
-def merge_dicts(source, merging, validator=None, *args):
-    _recurse_merge_dicts(source, merging, None, validator)
+MERGE_NEW_VALUES = 0
+MERGE_OVERWRITE_VALUES = 1
+MERGE_PREPEND_LISTS = 2
+MERGE_APPEND_LISTS = 4
+MERGE_ALL = MERGE_OVERWRITE_VALUES | MERGE_PREPEND_LISTS
+
+
+def merge_dicts(source, merging, *args,
+                validator=None, mode=MERGE_ALL):
+    _recurse_merge_dicts(source, merging, None, validator, mode)
     for other in args:
-        _recurse_merge_dicts(source, other, None, validator)
+        _recurse_merge_dicts(source, other, None, validator, mode)
     return source
 
 
-def _recurse_merge_dicts(local_cur, incoming_cur, parent_path, validator):
+def _recurse_merge_dicts(local_cur, incoming_cur, parent_path,
+                         validator, mode):
     for k, v in incoming_cur.items():
         key_path = k
         if parent_path is not None:
@@ -169,17 +187,24 @@
         local_v = local_cur.get(k)
         if local_v is not None:
             if isinstance(v, dict) and isinstance(local_v, dict):
-                _recurse_merge_dicts(local_v, v, key_path, validator)
+                _recurse_merge_dicts(local_v, v, key_path,
+                                     validator, mode)
             elif isinstance(v, list) and isinstance(local_v, list):
-                local_cur[k] = v + local_v
+                if mode & MERGE_PREPEND_LISTS:
+                    local_cur[k] = v + local_v
+                elif mode & MERGE_APPEND_LISTS:
+                    local_cur[k] = local_v + v
             else:
+                if mode & MERGE_OVERWRITE_VALUES:
+                    if validator is not None:
+                        v = validator(key_path, v)
+                    local_cur[k] = v
+        else:
+            if ((mode & (MERGE_PREPEND_LISTS | MERGE_APPEND_LISTS)) or
+                    not isinstance(v, list)):
                 if validator is not None:
                     v = validator(key_path, v)
                 local_cur[k] = v
-        else:
-            if validator is not None:
-                v = validator(key_path, v)
-            local_cur[k] = v
 
 
 def visit_dict(subject, visitor):
--- a/piecrust/publishing/base.py	Wed Oct 12 21:01:42 2016 -0700
+++ b/piecrust/publishing/base.py	Mon Oct 10 21:41:59 2016 -0700
@@ -45,7 +45,7 @@
     def getConfigValue(self, name, default_value=None):
         if self.has_url_config:
             raise Exception("This publisher only has a URL configuration.")
-        return try_get_dict_value(self.config, name, default_value)
+        return try_get_dict_value(self.config, name, default=default_value)
 
     def run(self, ctx):
         raise NotImplementedError()
--- a/tests/mockutil.py	Wed Oct 12 21:01:42 2016 -0700
+++ b/tests/mockutil.py	Mon Oct 10 21:41:59 2016 -0700
@@ -7,7 +7,7 @@
 
 def get_mock_app(config=None):
     app = mock.MagicMock(spec=PieCrust)
-    app.config = PieCrustConfiguration()
+    app.config = PieCrustConfiguration(values={})
     return app
 
 
--- a/tests/test_appconfig.py	Wed Oct 12 21:01:42 2016 -0700
+++ b/tests/test_appconfig.py	Mon Oct 10 21:41:59 2016 -0700
@@ -10,12 +10,6 @@
     assert len(config.get('site/sources')) == 3  # pages, posts, theme_pages
 
 
-def test_config_default2():
-    config = PieCrustConfiguration()
-    assert config.get('site/root') == '/'
-    assert len(config.get('site/sources')) == 3  # pages, posts, theme_pages
-
-
 def test_config_site_override_title():
     values = {'site': {'title': "Whatever"}}
     config = PieCrustConfiguration(values=values)
@@ -33,18 +27,20 @@
         app = fs.getApp()
         assert app.config.get('site/default_page_layout') == 'foo'
         assert app.config.get('site/default_post_layout') == 'bar'
-        assert app.config.get('site/sources')['pages']['default_layout'] == 'foo'
-        assert app.config.get('site/sources')['pages']['items_per_page'] == 5
-        assert app.config.get('site/sources')['theme_pages']['default_layout'] == 'default'
-        assert app.config.get('site/sources')['theme_pages']['items_per_page'] == 5
-        assert app.config.get('site/sources')['posts']['default_layout'] == 'bar'
-        assert app.config.get('site/sources')['posts']['items_per_page'] == 2
+        assert app.config.get('site/sources/pages/default_layout') == 'foo'
+        assert app.config.get('site/sources/pages/items_per_page') == 5
+        assert app.config.get(
+            'site/sources/theme_pages/default_layout') == 'default'
+        assert app.config.get('site/sources/theme_pages/items_per_page') == 5
+        assert app.config.get('site/sources/posts/default_layout') == 'bar'
+        assert app.config.get('site/sources/posts/items_per_page') == 2
+
 
 def test_config_site_add_source():
     config = {'site': {
         'sources': {'notes': {}},
         'routes': [{'url': '/notes/%path:slug%', 'source': 'notes'}]
-        }}
+    }}
     fs = mock_fs().withConfig(config)
     with mock_fs_scope(fs):
         app = fs.getApp()
@@ -53,23 +49,25 @@
             map(
                 lambda v: v.get('generator') or v['source'],
                 app.config.get('site/routes'))) ==
-            ['notes', 'posts', 'posts_archives', 'posts_tags', 'posts_categories', 'pages', 'theme_pages'])
-        assert list(app.config.get('site/sources').keys()) == [
-            'theme_pages', 'pages', 'posts', 'notes']
+                [
+                    'notes', 'posts', 'posts_archives', 'posts_tags',
+                    'posts_categories', 'pages', 'theme_pages'])
+        assert set(app.config.get('site/sources').keys()) == set([
+            'theme_pages', 'pages', 'posts', 'notes'])
 
 
 def test_config_site_add_source_in_both_site_and_theme():
     theme_config = {'site': {
         'sources': {'theme_notes': {}},
         'routes': [{'url': '/theme_notes/%path:slug%', 'source': 'theme_notes'}]
-        }}
+    }}
     config = {'site': {
         'sources': {'notes': {}},
         'routes': [{'url': '/notes/%path:slug%', 'source': 'notes'}]
-        }}
+    }}
     fs = (mock_fs()
-            .withConfig(config)
-            .withFile('kitchen/theme/theme_config.yml', yaml.dump(theme_config)))
+          .withConfig(config)
+          .withFile('kitchen/theme/theme_config.yml', yaml.dump(theme_config)))
     with mock_fs_scope(fs):
         app = fs.getApp()
         # The order of routes is important. Sources, not so much.
@@ -78,7 +76,43 @@
             map(
                 lambda v: v.get('generator') or v['source'],
                 app.config.get('site/routes'))) ==
-            ['notes', 'posts', 'posts_archives', 'posts_tags', 'posts_categories', 'pages', 'theme_notes', 'theme_pages'])
-        assert list(app.config.get('site/sources').keys()) == [
-            'theme_pages', 'theme_notes', 'pages', 'posts', 'notes']
+                [
+                    'notes', 'posts', 'posts_archives', 'posts_tags',
+                    'posts_categories', 'pages', 'theme_notes',
+                    'theme_pages'])
+        assert set(app.config.get('site/sources').keys()) == set([
+            'theme_pages', 'theme_notes', 'pages', 'posts', 'notes'])
+
 
+def test_multiple_blogs():
+    config = {'site': {'blogs': ['aaa', 'bbb']}}
+    fs = mock_fs().withConfig(config)
+    with mock_fs_scope(fs):
+        app = fs.getApp()
+        assert app.config.get('site/blogs') == ['aaa', 'bbb']
+        assert (list(
+            map(
+                lambda v: v.get('generator') or v['source'],
+                app.config.get('site/routes'))) ==
+                [
+                    'aaa', 'aaa_archives', 'aaa_tags', 'aaa_categories',
+                    'bbb', 'bbb_archives', 'bbb_tags', 'bbb_categories',
+                    'pages', 'theme_pages'])
+        assert set(app.config.get('site/sources').keys()) == set([
+            'aaa', 'bbb', 'pages', 'theme_pages'])
+
+
+def test_custom_list_setting():
+    config = {'blah': ['foo', 'bar']}
+    fs = mock_fs().withConfig(config)
+    with mock_fs_scope(fs):
+        app = fs.getApp()
+        assert app.config.get('blah') == ['foo', 'bar']
+
+
+def test_custom_list_setting_in_site_section():
+    config = {'site': {'blah': ['foo', 'bar']}}
+    fs = mock_fs().withConfig(config)
+    with mock_fs_scope(fs):
+        app = fs.getApp()
+        assert app.config.get('site/blah') == ['foo', 'bar']