diff piecrust/appconfig.py @ 683:ec384174b8b2

internal: More work/fixes on how default/theme/user configs are merged. Change how the code is organized to have better data flow. Add some tests.
author Ludovic Chabant <ludovic@chabant.com>
date Wed, 09 Mar 2016 00:23:51 -0800
parents 894d286b348f
children 1a6c4c2683fd
line wrap: on
line diff
--- a/piecrust/appconfig.py	Tue Mar 08 01:07:56 2016 -0800
+++ b/piecrust/appconfig.py	Wed Mar 09 00:23:51 2016 -0800
@@ -14,7 +14,7 @@
 from piecrust.cache import NullCache
 from piecrust.configuration import (
         Configuration, ConfigurationError, ConfigurationLoader,
-        get_dict_value, set_dict_value, merge_dicts, visit_dict)
+        try_get_dict_value, set_dict_value, merge_dicts, visit_dict)
 from piecrust.sources.base import REALM_USER, REALM_THEME
 
 
@@ -31,6 +31,9 @@
 class PieCrustConfiguration(Configuration):
     def __init__(self, *, path=None, theme_path=None, values=None,
                  cache=None, validate=True, theme_config=False):
+        if theme_config and theme_path:
+            raise Exception("Can't be a theme site config and still have a "
+                            "theme applied.")
         super(PieCrustConfiguration, self).__init__()
         self._path = path
         self._theme_path = theme_path
@@ -61,33 +64,35 @@
     def addVariantValue(self, path, value):
         def _fixup(config):
             set_dict_value(config, path, value)
+
         self._post_fixups.append(_fixup)
 
+    def setAll(self, values, validate=False):
+        # Override base class implementation
+        values = self._combineConfigs({}, values)
+        if validate:
+            values = self._validateAll(values)
+        self._values = values
+
     def _ensureNotLoaded(self):
         if self._values is not None:
             raise Exception("The configurations has been loaded.")
 
     def _load(self):
-        paths_and_fixups = []
-        if self._theme_path:
-            paths_and_fixups.append((self._theme_path, self._fixupThemeConfig))
-        if self._path:
-            paths_and_fixups.append((self._path, None))
-        paths_and_fixups += [(p, None) for p in self._custom_paths]
+        # 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))
 
-        if not paths_and_fixups:
-            self._values = self._validateAll({})
-            return
-
-        path_times = [os.path.getmtime(p[0]) for p in paths_and_fixups]
-
+        # Build the cache-key.
+        path_times = [os.path.getmtime(p[0]) for p in paths]
         cache_key_hash = hashlib.md5(
                 ("version=%s&cache=%d" % (
                     APP_VERSION, CACHE_VERSION)).encode('utf8'))
-        for p, _ in paths_and_fixups:
+        for p in paths:
             cache_key_hash.update(("&path=%s" % p).encode('utf8'))
         cache_key = cache_key_hash.hexdigest()
 
+        # Check the cache for a valid version.
         if self._cache.isValid('config.json', path_times):
             logger.debug("Loading configuration from cache...")
             config_text = self._cache.read('config.json')
@@ -97,34 +102,49 @@
 
             actual_cache_key = self._values.get('__cache_key')
             if actual_cache_key == cache_key:
+                # The cached version has the same key! Awesome!
                 self._values['__cache_valid'] = True
                 return
             logger.debug("Outdated cache key '%s' (expected '%s')." % (
                     actual_cache_key, cache_key))
 
-        logger.debug("Loading configuration from: %s" %
-                     ', '.join([p[0] for p in paths_and_fixups]))
-        values = {}
+        # Nope, load from the paths.
         try:
-            for p, f in paths_and_fixups:
-                with open(p, 'r', encoding='utf-8') as fp:
-                    loaded_values = yaml.load(
-                            fp.read(),
-                            Loader=ConfigurationLoader)
-                if loaded_values is None:
-                    loaded_values = {}
-                if f:
-                    f(loaded_values)
-                merge_dicts(values, loaded_values)
+            # Theme config.
+            theme_values = {}
+            if self._theme_path:
+                theme_values = self._loadFrom(self._theme_path)
+
+            # Site config.
+            site_values = {}
+            if self._path:
+                site_values = self._loadFrom(self._path)
+
+            # Combine!
+            logger.debug("Processing loaded configurations...")
+            values = self._combineConfigs(theme_values, site_values)
 
-            for f in self._post_fixups:
-                f(values)
+            # 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)
+
+            # 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)
 
             self._values = self._validateAll(values)
         except Exception as ex:
             raise Exception(
                     "Error loading configuration from: %s" %
-                    ', '.join([p[0] for p in paths_and_fixups])) from ex
+                    ', '.join(paths)) from ex
 
         logger.debug("Caching configuration...")
         self._values['__cache_key'] = cache_key
@@ -133,19 +153,59 @@
 
         self._values['__cache_valid'] = False
 
+    def _loadFrom(self, path):
+        logger.debug("Loading configuration from: %s" % path)
+        with open(path, 'r', encoding='utf-8') as fp:
+            values = yaml.load(
+                    fp.read(),
+                    Loader=ConfigurationLoader)
+        if values is None:
+            values = {}
+        return values
+
+    def _combineConfigs(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)
+
+        # Make all sources belong to the "theme" realm at this point.
+        srcc = values['site'].get('sources')
+        if srcc:
+            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)
+
+        # Set the theme site flag.
+        if self.theme_config:
+            values['site']['theme_site'] = True
+
+        return values
+
     def _validateAll(self, values):
         if values is None:
             values = {}
 
-        # We 'prepend' newer values to default values, so we need to do
-        # things in the following order so that the final list is made of:
-        # (1) user values, (2) site values, (3) theme values.
-        # Still, we need to do a few theme-related things before generating
-        # the default site content model.
-        values = self._preValidateThemeConfig(values)
-        values = self._validateSiteConfig(values)
-        values = self._validateThemeConfig(values)
-
         # Add a section for our cached information, and start visiting
         # the configuration tree, calling validation functions as we
         # find them.
@@ -172,76 +232,30 @@
 
         return values
 
-    def _fixupThemeConfig(self, values):
-        # Make all sources belong to the "theme" realm.
-        sitec = values.get('site')
-        if sitec:
-            srcc = sitec.get('sources')
-            if srcc:
-                for sn, sc in srcc.items():
-                    sc['realm'] = REALM_THEME
-
-    def _preValidateThemeConfig(self, values):
-        if not self._theme_path:
-            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)
 
-        sitec = values.setdefault('site', collections.OrderedDict())
-        gen_default_theme_model = bool(sitec.setdefault(
-                'use_default_theme_content', True))
-        if gen_default_theme_model:
-            pmcopy = copy.deepcopy(default_theme_content_pre_model)
-            values = merge_dicts(pmcopy, values)
-        return values
-
-
-    def _validateThemeConfig(self, values):
-        if not self._theme_path:
-            return values
+    def _generateDefaultSiteModel(self, values, user_overrides):
+        logger.debug("Generating default content model...")
+        cc = copy.deepcopy(default_content_model_base)
+        merge_dicts(values, cc)
 
-        # Create the default theme content model if needed.
-        sitec = values.setdefault('site', collections.OrderedDict())
-        gen_default_theme_model = bool(sitec.setdefault(
-                'use_default_theme_content', True))
-        if gen_default_theme_model:
-            logger.debug("Generating default theme content model...")
-            dcmcopy = copy.deepcopy(default_theme_content_model_base)
-            values = merge_dicts(dcmcopy, values)
-        return values
-
-
-    def _validateSiteConfig(self, values):
-        # Add the loaded values to the default configuration.
-        dccopy = copy.deepcopy(default_configuration)
-        values = merge_dicts(dccopy, values)
-
-        # Set the theme site flag.
-        sitec = values['site']
-        if self.theme_config:
-            sitec['theme_site'] = True
+        dcm = get_default_content_model(values, user_overrides)
+        merge_dicts(values, dcm)
 
-        # Create the default content model if needed.
-        gen_default_model = bool(sitec['use_default_content'])
-        if gen_default_model:
-            logger.debug("Generating default content model...")
-            dcmcopy = copy.deepcopy(default_content_model_base)
-            values = merge_dicts(dcmcopy, values)
-
-            blogsc = values['site'].get('blogs')
-            if blogsc is None:
-                blogsc = ['posts']
-                values['site']['blogs'] = blogsc
+        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,
-                        theme_site=self.theme_config)
-                values = merge_dicts(blog_cfg, values)
-
-            dcm = get_default_content_model(values)
-            values = merge_dicts(dcm, values)
-
-        return values
+        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):
@@ -253,6 +267,32 @@
         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': '/%path:slug%',
+                    'source': 'theme_pages',
+                    'func': 'pcurl(slug)'
+                    }
+                ],
+            'theme_tag_page': 'theme_pages:_tag.%ext%',
+            'theme_category_page': 'theme_pages:_category.%ext%'
+            })
+        })
+
+
 default_configuration = collections.OrderedDict({
         'site': collections.OrderedDict({
             'title': "Untitled PieCrust website",
@@ -276,6 +316,7 @@
             'enable_debug_info': True,
             'show_debug_info': False,
             'use_default_content': True,
+            'use_default_theme_content': True,
             'theme_site': False
             }),
         'baker': collections.OrderedDict({
@@ -299,40 +340,10 @@
         })
 
 
-default_theme_content_pre_model = collections.OrderedDict({
-        'site': collections.OrderedDict({
-            'theme_tag_page': 'theme_pages:_tag.%ext%',
-            'theme_category_page': 'theme_pages:_category.%ext%'
-            })
-        })
-
-
-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': '/%path:slug%',
-                    'source': 'theme_pages',
-                    'func': 'pcurl(slug)'
-                    }
-                ]
-            })
-        })
-
-
-def get_default_content_model(values):
-    default_layout = values['site']['default_page_layout']
+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({
@@ -365,10 +376,17 @@
 
 
 def get_default_content_model_for_blog(
-        blog_name, is_only_blog, values, theme_site=False):
-    posts_fs = values['site']['posts_fs']
-    blog_cfg = values.get(blog_name, {})
+        blog_name, is_only_blog, values, user_overrides, theme_site=False):
+    # Get the global values for various things we're interested in.
+    defs = {}
+    names = ['posts_fs', 'posts_per_page', 'date_format',
+             'default_post_layout', 'post_url', 'tag_url', 'category_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 = ''
         tax_page_prefix = ''
@@ -388,22 +406,24 @@
         data_endpoint = blog_name
         item_name = '%s-post' % blog_name
 
-    items_per_page = blog_cfg.get(
-            'posts_per_page', values['site']['posts_per_page'])
-    date_format = blog_cfg.get(
-            'date_format', values['site']['date_format'])
-    default_layout = blog_cfg.get(
-            'default_layout', values['site']['default_post_layout'])
+    # 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])
+        if n in ['post_url', 'tag_url', 'category_url']:
+            blog_values[n] = url_prefix + blog_values[n]
 
-    post_url = '/' + blog_cfg.get(
-            'post_url',
-            url_prefix + values['site']['post_url']).lstrip('/')
-    tag_url = '/' + blog_cfg.get(
-            'tag_url',
-            url_prefix + values['site']['tag_url']).lstrip('/')
-    category_url = '/' + blog_cfg.get(
-            'category_url',
-            url_prefix + values['site']['category_url']).lstrip('/')
+    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 = '/' + blog_values['post_url'].lstrip('/')
+    tag_url = '/' + blog_values['tag_url'].lstrip('/')
+    category_url = '/' + blog_values['category_url'].lstrip('/')
 
     tags_taxonomy = 'pages:%s_tag.%%ext%%' % tax_page_prefix
     category_taxonomy = 'pages:%s_category.%%ext%%' % tax_page_prefix
@@ -425,7 +445,7 @@
                         'item_name': item_name,
                         'ignore_missing_dir': True,
                         'data_type': 'blog',
-                        'items_per_page': items_per_page,
+                        'items_per_page': posts_per_page,
                         'date_format': date_format,
                         'default_layout': default_layout,
                         'taxonomy_pages': collections.OrderedDict({