comparison 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
comparison
equal deleted inserted replaced
682:99112a431de9 683:ec384174b8b2
12 DEFAULT_FORMAT, DEFAULT_TEMPLATE_ENGINE, DEFAULT_POSTS_FS, 12 DEFAULT_FORMAT, DEFAULT_TEMPLATE_ENGINE, DEFAULT_POSTS_FS,
13 DEFAULT_DATE_FORMAT, DEFAULT_THEME_SOURCE) 13 DEFAULT_DATE_FORMAT, DEFAULT_THEME_SOURCE)
14 from piecrust.cache import NullCache 14 from piecrust.cache import NullCache
15 from piecrust.configuration import ( 15 from piecrust.configuration import (
16 Configuration, ConfigurationError, ConfigurationLoader, 16 Configuration, ConfigurationError, ConfigurationLoader,
17 get_dict_value, set_dict_value, merge_dicts, visit_dict) 17 try_get_dict_value, set_dict_value, merge_dicts, visit_dict)
18 from piecrust.sources.base import REALM_USER, REALM_THEME 18 from piecrust.sources.base import REALM_USER, REALM_THEME
19 19
20 20
21 logger = logging.getLogger(__name__) 21 logger = logging.getLogger(__name__)
22 22
29 29
30 30
31 class PieCrustConfiguration(Configuration): 31 class PieCrustConfiguration(Configuration):
32 def __init__(self, *, path=None, theme_path=None, values=None, 32 def __init__(self, *, path=None, theme_path=None, values=None,
33 cache=None, validate=True, theme_config=False): 33 cache=None, validate=True, theme_config=False):
34 if theme_config and theme_path:
35 raise Exception("Can't be a theme site config and still have a "
36 "theme applied.")
34 super(PieCrustConfiguration, self).__init__() 37 super(PieCrustConfiguration, self).__init__()
35 self._path = path 38 self._path = path
36 self._theme_path = theme_path 39 self._theme_path = theme_path
37 self._cache = cache or NullCache() 40 self._cache = cache or NullCache()
38 self._custom_paths = [] 41 self._custom_paths = []
59 62
60 63
61 def addVariantValue(self, path, value): 64 def addVariantValue(self, path, value):
62 def _fixup(config): 65 def _fixup(config):
63 set_dict_value(config, path, value) 66 set_dict_value(config, path, value)
67
64 self._post_fixups.append(_fixup) 68 self._post_fixups.append(_fixup)
69
70 def setAll(self, values, validate=False):
71 # Override base class implementation
72 values = self._combineConfigs({}, values)
73 if validate:
74 values = self._validateAll(values)
75 self._values = values
65 76
66 def _ensureNotLoaded(self): 77 def _ensureNotLoaded(self):
67 if self._values is not None: 78 if self._values is not None:
68 raise Exception("The configurations has been loaded.") 79 raise Exception("The configurations has been loaded.")
69 80
70 def _load(self): 81 def _load(self):
71 paths_and_fixups = [] 82 # Figure out where to load this configuration from.
72 if self._theme_path: 83 paths = [self._theme_path, self._path] + self._custom_paths
73 paths_and_fixups.append((self._theme_path, self._fixupThemeConfig)) 84 paths = list(filter(lambda i: i is not None, paths))
74 if self._path: 85
75 paths_and_fixups.append((self._path, None)) 86 # Build the cache-key.
76 paths_and_fixups += [(p, None) for p in self._custom_paths] 87 path_times = [os.path.getmtime(p[0]) for p in paths]
77
78 if not paths_and_fixups:
79 self._values = self._validateAll({})
80 return
81
82 path_times = [os.path.getmtime(p[0]) for p in paths_and_fixups]
83
84 cache_key_hash = hashlib.md5( 88 cache_key_hash = hashlib.md5(
85 ("version=%s&cache=%d" % ( 89 ("version=%s&cache=%d" % (
86 APP_VERSION, CACHE_VERSION)).encode('utf8')) 90 APP_VERSION, CACHE_VERSION)).encode('utf8'))
87 for p, _ in paths_and_fixups: 91 for p in paths:
88 cache_key_hash.update(("&path=%s" % p).encode('utf8')) 92 cache_key_hash.update(("&path=%s" % p).encode('utf8'))
89 cache_key = cache_key_hash.hexdigest() 93 cache_key = cache_key_hash.hexdigest()
90 94
95 # Check the cache for a valid version.
91 if self._cache.isValid('config.json', path_times): 96 if self._cache.isValid('config.json', path_times):
92 logger.debug("Loading configuration from cache...") 97 logger.debug("Loading configuration from cache...")
93 config_text = self._cache.read('config.json') 98 config_text = self._cache.read('config.json')
94 self._values = json.loads( 99 self._values = json.loads(
95 config_text, 100 config_text,
96 object_pairs_hook=collections.OrderedDict) 101 object_pairs_hook=collections.OrderedDict)
97 102
98 actual_cache_key = self._values.get('__cache_key') 103 actual_cache_key = self._values.get('__cache_key')
99 if actual_cache_key == cache_key: 104 if actual_cache_key == cache_key:
105 # The cached version has the same key! Awesome!
100 self._values['__cache_valid'] = True 106 self._values['__cache_valid'] = True
101 return 107 return
102 logger.debug("Outdated cache key '%s' (expected '%s')." % ( 108 logger.debug("Outdated cache key '%s' (expected '%s')." % (
103 actual_cache_key, cache_key)) 109 actual_cache_key, cache_key))
104 110
105 logger.debug("Loading configuration from: %s" % 111 # Nope, load from the paths.
106 ', '.join([p[0] for p in paths_and_fixups]))
107 values = {}
108 try: 112 try:
109 for p, f in paths_and_fixups: 113 # Theme config.
110 with open(p, 'r', encoding='utf-8') as fp: 114 theme_values = {}
111 loaded_values = yaml.load( 115 if self._theme_path:
112 fp.read(), 116 theme_values = self._loadFrom(self._theme_path)
113 Loader=ConfigurationLoader) 117
114 if loaded_values is None: 118 # Site config.
115 loaded_values = {} 119 site_values = {}
116 if f: 120 if self._path:
117 f(loaded_values) 121 site_values = self._loadFrom(self._path)
118 merge_dicts(values, loaded_values) 122
119 123 # Combine!
120 for f in self._post_fixups: 124 logger.debug("Processing loaded configurations...")
121 f(values) 125 values = self._combineConfigs(theme_values, site_values)
126
127 # Load additional paths.
128 if self._custom_paths:
129 logger.debug("Loading %d additional configuration paths." %
130 len(self._custom_paths))
131 for p in self._custom_paths:
132 loaded = self._loadFrom(p)
133 if loaded:
134 merge_dicts(values, loaded)
135
136 # Run final fixups
137 if self._post_fixups:
138 logger.debug("Applying %d configuration fixups." %
139 len(self._post_fixups))
140 for f in self._post_fixups:
141 f(values)
122 142
123 self._values = self._validateAll(values) 143 self._values = self._validateAll(values)
124 except Exception as ex: 144 except Exception as ex:
125 raise Exception( 145 raise Exception(
126 "Error loading configuration from: %s" % 146 "Error loading configuration from: %s" %
127 ', '.join([p[0] for p in paths_and_fixups])) from ex 147 ', '.join(paths)) from ex
128 148
129 logger.debug("Caching configuration...") 149 logger.debug("Caching configuration...")
130 self._values['__cache_key'] = cache_key 150 self._values['__cache_key'] = cache_key
131 config_text = json.dumps(self._values) 151 config_text = json.dumps(self._values)
132 self._cache.write('config.json', config_text) 152 self._cache.write('config.json', config_text)
133 153
134 self._values['__cache_valid'] = False 154 self._values['__cache_valid'] = False
135 155
156 def _loadFrom(self, path):
157 logger.debug("Loading configuration from: %s" % path)
158 with open(path, 'r', encoding='utf-8') as fp:
159 values = yaml.load(
160 fp.read(),
161 Loader=ConfigurationLoader)
162 if values is None:
163 values = {}
164 return values
165
166 def _combineConfigs(self, theme_values, site_values):
167 # Start with the default configuration.
168 values = copy.deepcopy(default_configuration)
169
170 if not self.theme_config:
171 # If the theme config wants the default model, add it.
172 theme_sitec = theme_values.setdefault(
173 'site', collections.OrderedDict())
174 gen_default_theme_model = bool(theme_sitec.setdefault(
175 'use_default_theme_content', True))
176 if gen_default_theme_model:
177 self._generateDefaultThemeModel(values)
178
179 # Now override with the actual theme config values.
180 values = merge_dicts(values, theme_values)
181
182 # Make all sources belong to the "theme" realm at this point.
183 srcc = values['site'].get('sources')
184 if srcc:
185 for sn, sc in srcc.items():
186 sc['realm'] = REALM_THEME
187
188 # If the site config wants the default model, add it.
189 site_sitec = site_values.setdefault(
190 'site', collections.OrderedDict())
191 gen_default_site_model = bool(site_sitec.setdefault(
192 'use_default_content', True))
193 if gen_default_site_model:
194 self._generateDefaultSiteModel(values, site_values)
195
196 # And override with the actual site config values.
197 values = merge_dicts(values, site_values)
198
199 # Set the theme site flag.
200 if self.theme_config:
201 values['site']['theme_site'] = True
202
203 return values
204
136 def _validateAll(self, values): 205 def _validateAll(self, values):
137 if values is None: 206 if values is None:
138 values = {} 207 values = {}
139
140 # We 'prepend' newer values to default values, so we need to do
141 # things in the following order so that the final list is made of:
142 # (1) user values, (2) site values, (3) theme values.
143 # Still, we need to do a few theme-related things before generating
144 # the default site content model.
145 values = self._preValidateThemeConfig(values)
146 values = self._validateSiteConfig(values)
147 values = self._validateThemeConfig(values)
148 208
149 # Add a section for our cached information, and start visiting 209 # Add a section for our cached information, and start visiting
150 # the configuration tree, calling validation functions as we 210 # the configuration tree, calling validation functions as we
151 # find them. 211 # find them.
152 cachec = collections.OrderedDict() 212 cachec = collections.OrderedDict()
170 230
171 visit_dict(values, _visitor) 231 visit_dict(values, _visitor)
172 232
173 return values 233 return values
174 234
175 def _fixupThemeConfig(self, values): 235 def _generateDefaultThemeModel(self, values):
176 # Make all sources belong to the "theme" realm. 236 logger.debug("Generating default theme content model...")
177 sitec = values.get('site') 237 cc = copy.deepcopy(default_theme_content_model_base)
178 if sitec: 238 merge_dicts(values, cc)
179 srcc = sitec.get('sources') 239
180 if srcc: 240 def _generateDefaultSiteModel(self, values, user_overrides):
181 for sn, sc in srcc.items(): 241 logger.debug("Generating default content model...")
182 sc['realm'] = REALM_THEME 242 cc = copy.deepcopy(default_content_model_base)
183 243 merge_dicts(values, cc)
184 def _preValidateThemeConfig(self, values): 244
185 if not self._theme_path: 245 dcm = get_default_content_model(values, user_overrides)
186 return values 246 merge_dicts(values, dcm)
187 247
188 sitec = values.setdefault('site', collections.OrderedDict()) 248 blogsc = try_get_dict_value(user_overrides, 'site/blogs')
189 gen_default_theme_model = bool(sitec.setdefault( 249 if blogsc is None:
190 'use_default_theme_content', True)) 250 blogsc = ['posts']
191 if gen_default_theme_model: 251 set_dict_value(user_overrides, 'site/blogs', blogsc)
192 pmcopy = copy.deepcopy(default_theme_content_pre_model) 252
193 values = merge_dicts(pmcopy, values) 253 is_only_blog = (len(blogsc) == 1)
194 return values 254 for blog_name in blogsc:
195 255 blog_cfg = get_default_content_model_for_blog(
196 256 blog_name, is_only_blog, values, user_overrides,
197 def _validateThemeConfig(self, values): 257 theme_site=self.theme_config)
198 if not self._theme_path: 258 merge_dicts(values, blog_cfg)
199 return values
200
201 # Create the default theme content model if needed.
202 sitec = values.setdefault('site', collections.OrderedDict())
203 gen_default_theme_model = bool(sitec.setdefault(
204 'use_default_theme_content', True))
205 if gen_default_theme_model:
206 logger.debug("Generating default theme content model...")
207 dcmcopy = copy.deepcopy(default_theme_content_model_base)
208 values = merge_dicts(dcmcopy, values)
209 return values
210
211
212 def _validateSiteConfig(self, values):
213 # Add the loaded values to the default configuration.
214 dccopy = copy.deepcopy(default_configuration)
215 values = merge_dicts(dccopy, values)
216
217 # Set the theme site flag.
218 sitec = values['site']
219 if self.theme_config:
220 sitec['theme_site'] = True
221
222 # Create the default content model if needed.
223 gen_default_model = bool(sitec['use_default_content'])
224 if gen_default_model:
225 logger.debug("Generating default content model...")
226 dcmcopy = copy.deepcopy(default_content_model_base)
227 values = merge_dicts(dcmcopy, values)
228
229 blogsc = values['site'].get('blogs')
230 if blogsc is None:
231 blogsc = ['posts']
232 values['site']['blogs'] = blogsc
233
234 is_only_blog = (len(blogsc) == 1)
235 for blog_name in blogsc:
236 blog_cfg = get_default_content_model_for_blog(
237 blog_name, is_only_blog, values,
238 theme_site=self.theme_config)
239 values = merge_dicts(blog_cfg, values)
240
241 dcm = get_default_content_model(values)
242 values = merge_dicts(dcm, values)
243
244 return values
245 259
246 260
247 class _ConfigCacheWriter(object): 261 class _ConfigCacheWriter(object):
248 def __init__(self, cache_dict): 262 def __init__(self, cache_dict):
249 self._cache_dict = cache_dict 263 self._cache_dict = cache_dict
250 264
251 def write(self, name, val): 265 def write(self, name, val):
252 logger.debug("Caching configuration item '%s' = %s" % (name, val)) 266 logger.debug("Caching configuration item '%s' = %s" % (name, val))
253 self._cache_dict[name] = val 267 self._cache_dict[name] = val
268
269
270 default_theme_content_model_base = collections.OrderedDict({
271 'site': collections.OrderedDict({
272 'sources': collections.OrderedDict({
273 'theme_pages': {
274 'type': 'default',
275 'ignore_missing_dir': True,
276 'fs_endpoint': 'pages',
277 'data_endpoint': 'site.pages',
278 'default_layout': 'default',
279 'item_name': 'page',
280 'realm': REALM_THEME
281 }
282 }),
283 'routes': [
284 {
285 'url': '/%path:slug%',
286 'source': 'theme_pages',
287 'func': 'pcurl(slug)'
288 }
289 ],
290 'theme_tag_page': 'theme_pages:_tag.%ext%',
291 'theme_category_page': 'theme_pages:_category.%ext%'
292 })
293 })
254 294
255 295
256 default_configuration = collections.OrderedDict({ 296 default_configuration = collections.OrderedDict({
257 'site': collections.OrderedDict({ 297 'site': collections.OrderedDict({
258 'title': "Untitled PieCrust website", 298 'title': "Untitled PieCrust website",
274 'themes_sources': [DEFAULT_THEME_SOURCE], 314 'themes_sources': [DEFAULT_THEME_SOURCE],
275 'cache_time': 28800, 315 'cache_time': 28800,
276 'enable_debug_info': True, 316 'enable_debug_info': True,
277 'show_debug_info': False, 317 'show_debug_info': False,
278 'use_default_content': True, 318 'use_default_content': True,
319 'use_default_theme_content': True,
279 'theme_site': False 320 'theme_site': False
280 }), 321 }),
281 'baker': collections.OrderedDict({ 322 'baker': collections.OrderedDict({
282 'no_bake_setting': 'draft', 323 'no_bake_setting': 'draft',
283 'workers': None, 324 'workers': None,
297 'posts_per_page': 5 338 'posts_per_page': 5
298 }) 339 })
299 }) 340 })
300 341
301 342
302 default_theme_content_pre_model = collections.OrderedDict({ 343 def get_default_content_model(values, user_overrides):
303 'site': collections.OrderedDict({ 344 default_layout = try_get_dict_value(
304 'theme_tag_page': 'theme_pages:_tag.%ext%', 345 user_overrides, 'site/default_page_layout',
305 'theme_category_page': 'theme_pages:_category.%ext%' 346 values['site']['default_page_layout'])
306 })
307 })
308
309
310 default_theme_content_model_base = collections.OrderedDict({
311 'site': collections.OrderedDict({
312 'sources': collections.OrderedDict({
313 'theme_pages': {
314 'type': 'default',
315 'ignore_missing_dir': True,
316 'fs_endpoint': 'pages',
317 'data_endpoint': 'site.pages',
318 'default_layout': 'default',
319 'item_name': 'page',
320 'realm': REALM_THEME
321 }
322 }),
323 'routes': [
324 {
325 'url': '/%path:slug%',
326 'source': 'theme_pages',
327 'func': 'pcurl(slug)'
328 }
329 ]
330 })
331 })
332
333
334 def get_default_content_model(values):
335 default_layout = values['site']['default_page_layout']
336 return collections.OrderedDict({ 347 return collections.OrderedDict({
337 'site': collections.OrderedDict({ 348 'site': collections.OrderedDict({
338 'sources': collections.OrderedDict({ 349 'sources': collections.OrderedDict({
339 'pages': { 350 'pages': {
340 'type': 'default', 351 'type': 'default',
363 }) 374 })
364 }) 375 })
365 376
366 377
367 def get_default_content_model_for_blog( 378 def get_default_content_model_for_blog(
368 blog_name, is_only_blog, values, theme_site=False): 379 blog_name, is_only_blog, values, user_overrides, theme_site=False):
369 posts_fs = values['site']['posts_fs'] 380 # Get the global values for various things we're interested in.
370 blog_cfg = values.get(blog_name, {}) 381 defs = {}
371 382 names = ['posts_fs', 'posts_per_page', 'date_format',
383 'default_post_layout', 'post_url', 'tag_url', 'category_url']
384 for n in names:
385 defs[n] = try_get_dict_value(
386 user_overrides, 'site/%s' % n,
387 values['site'][n])
388
389 # More stuff we need.
372 if is_only_blog: 390 if is_only_blog:
373 url_prefix = '' 391 url_prefix = ''
374 tax_page_prefix = '' 392 tax_page_prefix = ''
375 fs_endpoint = 'posts' 393 fs_endpoint = 'posts'
376 data_endpoint = 'blog' 394 data_endpoint = 'blog'
386 tax_page_prefix = blog_name + '/' 404 tax_page_prefix = blog_name + '/'
387 fs_endpoint = 'posts/%s' % blog_name 405 fs_endpoint = 'posts/%s' % blog_name
388 data_endpoint = blog_name 406 data_endpoint = blog_name
389 item_name = '%s-post' % blog_name 407 item_name = '%s-post' % blog_name
390 408
391 items_per_page = blog_cfg.get( 409 # Figure out the settings values for this blog, specifically.
392 'posts_per_page', values['site']['posts_per_page']) 410 # The value could be set on the blog config itself, globally, or left at
393 date_format = blog_cfg.get( 411 # its default. We already handle the "globally vs. default" with the
394 'date_format', values['site']['date_format']) 412 # `defs` map that we computed above.
395 default_layout = blog_cfg.get( 413 blog_cfg = user_overrides.get(blog_name, {})
396 'default_layout', values['site']['default_post_layout']) 414 blog_values = {}
397 415 for n in names:
398 post_url = '/' + blog_cfg.get( 416 blog_values[n] = blog_cfg.get(n, defs[n])
399 'post_url', 417 if n in ['post_url', 'tag_url', 'category_url']:
400 url_prefix + values['site']['post_url']).lstrip('/') 418 blog_values[n] = url_prefix + blog_values[n]
401 tag_url = '/' + blog_cfg.get( 419
402 'tag_url', 420 posts_fs = blog_values['posts_fs']
403 url_prefix + values['site']['tag_url']).lstrip('/') 421 posts_per_page = blog_values['posts_per_page']
404 category_url = '/' + blog_cfg.get( 422 date_format = blog_values['date_format']
405 'category_url', 423 default_layout = blog_values['default_post_layout']
406 url_prefix + values['site']['category_url']).lstrip('/') 424 post_url = '/' + blog_values['post_url'].lstrip('/')
425 tag_url = '/' + blog_values['tag_url'].lstrip('/')
426 category_url = '/' + blog_values['category_url'].lstrip('/')
407 427
408 tags_taxonomy = 'pages:%s_tag.%%ext%%' % tax_page_prefix 428 tags_taxonomy = 'pages:%s_tag.%%ext%%' % tax_page_prefix
409 category_taxonomy = 'pages:%s_category.%%ext%%' % tax_page_prefix 429 category_taxonomy = 'pages:%s_category.%%ext%%' % tax_page_prefix
410 if not theme_site: 430 if not theme_site:
411 theme_tag_page = values['site'].get('theme_tag_page') 431 theme_tag_page = values['site'].get('theme_tag_page')
423 'fs_endpoint': fs_endpoint, 443 'fs_endpoint': fs_endpoint,
424 'data_endpoint': data_endpoint, 444 'data_endpoint': data_endpoint,
425 'item_name': item_name, 445 'item_name': item_name,
426 'ignore_missing_dir': True, 446 'ignore_missing_dir': True,
427 'data_type': 'blog', 447 'data_type': 'blog',
428 'items_per_page': items_per_page, 448 'items_per_page': posts_per_page,
429 'date_format': date_format, 449 'date_format': date_format,
430 'default_layout': default_layout, 450 'default_layout': default_layout,
431 'taxonomy_pages': collections.OrderedDict({ 451 'taxonomy_pages': collections.OrderedDict({
432 'tags': tags_taxonomy, 452 'tags': tags_taxonomy,
433 'categories': category_taxonomy 453 'categories': category_taxonomy