Mercurial > piecrust2
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 |