comparison piecrust/app.py @ 3:f485ba500df3

Gigantic change to basically make PieCrust 2 vaguely functional. - Serving works, with debug window. - Baking works, multi-threading, with dependency handling. - Various things not implemented yet.
author Ludovic Chabant <ludovic@chabant.com>
date Sun, 10 Aug 2014 23:43:16 -0700
parents aaa8fb7c8918
children 474c9882decf
comparison
equal deleted inserted replaced
2:40fa08b261b9 3:f485ba500df3
1 import re
1 import json 2 import json
2 import os.path 3 import os.path
3 import types 4 import types
4 import codecs 5 import codecs
5 import hashlib 6 import hashlib
6 import logging 7 import logging
7 import yaml 8 import yaml
8 from cache import SimpleCache 9 from werkzeug.utils import cached_property
9 from decorators import lazy_property 10 from piecrust import (APP_VERSION,
10 from plugins.base import PluginLoader 11 CACHE_DIR, TEMPLATES_DIR,
11 from environment import StandardEnvironment 12 PLUGINS_DIR, THEME_DIR,
12 from configuration import Configuration, merge_dicts 13 CONFIG_PATH, THEME_CONFIG_PATH,
13 14 DEFAULT_FORMAT, DEFAULT_TEMPLATE_ENGINE, DEFAULT_POSTS_FS,
14 15 DEFAULT_DATE_FORMAT, DEFAULT_PLUGIN_SOURCE, DEFAULT_THEME_SOURCE)
15 APP_VERSION = '2.0.0alpha' 16 from piecrust.cache import ExtensibleCache, NullCache, NullExtensibleCache
16 CACHE_VERSION = '2.0' 17 from piecrust.plugins.base import PluginLoader
17 18 from piecrust.environment import StandardEnvironment
18 CACHE_DIR = '_cache' 19 from piecrust.configuration import Configuration, ConfigurationError, merge_dicts
19 TEMPLATES_DIR = '_content/templates' 20 from piecrust.routing import Route
20 PAGES_DIR = '_content/pages' 21 from piecrust.sources.base import REALM_USER, REALM_THEME
21 POSTS_DIR = '_content/posts' 22 from piecrust.taxonomies import Taxonomy
22 PLUGINS_DIR = '_content/plugins'
23 THEME_DIR = '_content/theme'
24
25 CONFIG_PATH = '_content/config.yml'
26 THEME_CONFIG_PATH = '_content/theme_config.yml'
27 23
28 24
29 logger = logging.getLogger(__name__) 25 logger = logging.getLogger(__name__)
26
27
28 CACHE_VERSION = 10
30 29
31 30
32 class VariantNotFoundError(Exception): 31 class VariantNotFoundError(Exception):
33 def __init__(self, variant_path, message=None): 32 def __init__(self, variant_path, message=None):
34 super(VariantNotFoundError, self).__init__( 33 super(VariantNotFoundError, self).__init__(
35 message or ("No such configuration variant: %s" % variant_path)) 34 message or ("No such configuration variant: %s" % variant_path))
36 35
37 36
38 class PieCrustConfiguration(Configuration): 37 class PieCrustConfiguration(Configuration):
39 def __init__(self, paths=None, cache_dir=False): 38 def __init__(self, paths=None, cache=None, values=None, validate=True):
40 super(PieCrustConfiguration, self).__init__() 39 super(PieCrustConfiguration, self).__init__(values, validate)
41 self.paths = paths 40 self.paths = paths
42 self.cache_dir = cache_dir 41 self.cache = cache or NullCache()
43 self.fixups = [] 42 self.fixups = []
44 43
45 def applyVariant(self, variant_path, raise_if_not_found=True): 44 def applyVariant(self, variant_path, raise_if_not_found=True):
46 variant = self.get(variant_path) 45 variant = self.get(variant_path)
47 if variant is None: 46 if variant is None:
56 55
57 def _load(self): 56 def _load(self):
58 if self.paths is None: 57 if self.paths is None:
59 self._values = self._validateAll({}) 58 self._values = self._validateAll({})
60 return 59 return
61 60
62 path_times = filter(self.paths, 61 path_times = map(lambda p: os.path.getmtime(p), self.paths)
63 lambda p: os.path.getmtime(p)) 62 cache_key = hashlib.md5("version=%s&cache=%d" % (
64 cache_key = hashlib.md5("version=%s&cache=%s" % ( 63 APP_VERSION, CACHE_VERSION)).hexdigest()
65 APP_VERSION, CACHE_VERSION)) 64
66 65 if self.cache.isValid('config.json', path_times):
67 cache = None 66 logger.debug("Loading configuration from cache...")
68 if self.cache_dir: 67 config_text = self.cache.read('config.json')
69 cache = SimpleCache(self.cache_dir) 68 self._values = json.loads(config_text)
70 69
71 if cache is not None: 70 actual_cache_key = self._values.get('__cache_key')
72 if cache.isValid('config.json', path_times): 71 if actual_cache_key == cache_key:
73 config_text = cache.read('config.json') 72 return
74 self._values = json.loads(config_text) 73 logger.debug("Outdated cache key '%s' (expected '%s')." % (
75 74 actual_cache_key, cache_key))
76 actual_cache_key = self._values.get('__cache_key')
77 if actual_cache_key == cache_key:
78 return
79 75
80 values = {} 76 values = {}
77 logger.debug("Loading configuration from: %s" % self.paths)
81 for i, p in enumerate(self.paths): 78 for i, p in enumerate(self.paths):
82 with codecs.open(p, 'r', 'utf-8') as fp: 79 with codecs.open(p, 'r', 'utf-8') as fp:
83 loaded_values = yaml.load(fp.read()) 80 loaded_values = yaml.load(fp.read())
81 if loaded_values is None:
82 loaded_values = {}
84 for fixup in self.fixups: 83 for fixup in self.fixups:
85 fixup(i, loaded_values) 84 fixup(i, loaded_values)
86 merge_dicts(values, loaded_values) 85 merge_dicts(values, loaded_values)
87 86
88 for fixup in self.fixups: 87 for fixup in self.fixups:
89 fixup(len(self.paths), values) 88 fixup(len(self.paths), values)
90 89
91 self._values = self._validateAll(values) 90 self._values = self._validateAll(values)
92 91
93 if cache is not None: 92 logger.debug("Caching configuration...")
94 self._values['__cache_key'] = cache_key 93 self._values['__cache_key'] = cache_key
95 config_text = json.dumps(self._values) 94 config_text = json.dumps(self._values)
96 cache.write('config.json', config_text) 95 self.cache.write('config.json', config_text)
96
97 def _validateAll(self, values):
98 # Put all the defaults in the `site` section.
99 default_sitec = {
100 'title': "Untitled PieCrust website",
101 'root': '/',
102 'default_format': DEFAULT_FORMAT,
103 'default_template_engine': DEFAULT_TEMPLATE_ENGINE,
104 'enable_gzip': True,
105 'pretty_urls': False,
106 'slugify': 'transliterate|lowercase',
107 'timezone': False,
108 'locale': False,
109 'date_format': DEFAULT_DATE_FORMAT,
110 'auto_formats': {
111 'html': '',
112 'md': 'markdown',
113 'textile': 'textile'},
114 'default_auto_format': 'md',
115 'pagination_suffix': '/%num%',
116 'plugins_sources': [DEFAULT_PLUGIN_SOURCE],
117 'themes_sources': [DEFAULT_THEME_SOURCE],
118 'cache_time': 28800,
119 'display_errors': True,
120 'enable_debug_info': True
121 }
122 sitec = values.get('site')
123 if sitec is None:
124 sitec = {}
125 for key, val in default_sitec.iteritems():
126 sitec.setdefault(key, val)
127 values['site'] = sitec
128
129 # Add a section for our cached information.
130 cachec = {}
131 values['__cache'] = cachec
132
133 # Cache auto-format regexes.
134 if not isinstance(sitec['auto_formats'], dict):
135 raise ConfigurationError("The 'site/auto_formats' setting must be a dictionary.")
136 cachec['auto_formats_re'] = r"\.(%s)$" % (
137 '|'.join(
138 map(lambda i: re.escape(i), sitec['auto_formats'].keys())))
139 if sitec['default_auto_format'] not in sitec['auto_formats']:
140 raise ConfigurationError("Default auto-format '%s' is not declared." % sitec['default_auto_format'])
141
142 # Cache pagination suffix regex.
143 pgn_suffix = re.escape(sitec['pagination_suffix'])
144 pgn_suffix = pgn_suffix.replace("\\%num\\%", "(?P<num>\\d+)") + '$'
145 cachec['pagination_suffix_re'] = pgn_suffix
146
147 # Make sure plugins and theme sources are lists.
148 if not isinstance(sitec['plugins_sources'], list):
149 sitec['plugins_sources'] = [sitec['plugins_sources']]
150 if not isinstance(sitec['themes_sources'], list):
151 sitec['themes_sources'] = [sitec['themes_sources']]
152
153 # Setup values for posts/items.
154 ipp = sitec.get('posts_per_page')
155 if ipp is not None:
156 sitec.setdefault('items_per_page', ipp)
157 pf = sitec.get('posts_filters')
158 if pf is not None:
159 sitec.setdefault('items_filters', pf)
160
161 # Figure out if we need to validate sources/routes, or auto-generate
162 # them from simple blog settings.
163 if 'sources' not in sitec:
164 posts_fs = sitec.setdefault('posts_fs', DEFAULT_POSTS_FS)
165 blogsc = sitec.setdefault('blogs', ['posts'])
166
167 g_post_url = sitec.get('post_url', '%year%/%month%/%slug%')
168 g_tag_url = sitec.get('tag_url', 'tag/%tag%')
169 g_category_url = sitec.get('category_url', '%category%')
170 g_posts_per_page = sitec.get('items_per_page', 5)
171 g_posts_filters = sitec.get('items_filters')
172 g_date_format = sitec.get('date_format', DEFAULT_DATE_FORMAT)
173
174 sourcesc = {}
175 sourcesc['pages'] = {
176 'type': 'default',
177 'data_endpoint': 'site/pages',
178 'item_name': 'page'}
179 sitec['sources'] = sourcesc
180
181 routesc = []
182 sitec['routes'] = routesc
183
184 taxonomiesc = {}
185 taxonomiesc['tags'] = {
186 'multiple': True,
187 'term': 'tag'}
188 taxonomiesc['categories'] = {
189 'term': 'category'}
190 sitec['taxonomies'] = taxonomiesc
191
192 for blog_name in blogsc:
193 blogc = values.get(blog_name, {})
194 url_prefix = blog_name + '/'
195 endpoint = 'posts/%s' % blog_name
196 item_name = '%s-post' % blog_name
197 items_per_page = blogc.get('posts_per_page', g_posts_per_page)
198 items_filters = blogc.get('posts_filters', g_posts_filters)
199 date_format = blogc.get('date_format', g_date_format)
200 if len(blogsc) == 1:
201 url_prefix = ''
202 endpoint = 'posts'
203 item_name = 'post'
204 sourcesc[blog_name] = {
205 'type': 'posts/%s' % posts_fs,
206 'fs_endpoint': endpoint,
207 'data_type': 'blog',
208 'item_name': item_name,
209 'items_per_page': items_per_page,
210 'items_filters': items_filters,
211 'date_format': date_format,
212 'default_layout': 'post'}
213 tax_page_prefix = ''
214 if len(blogsc) > 1:
215 tax_page_prefix = blog_name + '/'
216 sourcesc[blog_name]['taxonomy_pages'] = {
217 'tags': ('pages:%s_tag.%%ext%%;'
218 'theme_pages:_tag.%%ext%%' %
219 tax_page_prefix),
220 'categories': ('pages:%s_category.%%ext%%;'
221 'theme_pages:_category.%%ext%%' %
222 tax_page_prefix)}
223
224 post_url = blogc.get('post_url', url_prefix + g_post_url)
225 post_url = '/' + post_url.lstrip('/')
226 tag_url = blogc.get('tag_url', url_prefix + g_tag_url)
227 tag_url = '/' + tag_url.lstrip('/')
228 category_url = blogc.get('category_url', url_prefix + g_category_url)
229 category_url = '/' + category_url.lstrip('/')
230 routesc.append({'url': post_url, 'source': blog_name,
231 'func': 'pcposturl(year,month,day,slug)'})
232 routesc.append({'url': tag_url, 'source': blog_name,
233 'taxonomy': 'tags',
234 'func': 'pctagurl(tag)'})
235 routesc.append({'url': category_url, 'source': blog_name,
236 'taxonomy': 'categories',
237 'func': 'pccaturl(category)'})
238
239 routesc.append({'url': '/%path:path%', 'source': 'pages',
240 'func': 'pcurl(path)'})
241
242 # Validate sources/routes.
243 sourcesc = sitec.get('sources')
244 routesc = sitec.get('routes')
245 if not sourcesc:
246 raise ConfigurationError("There are no sources defined.")
247 if not routesc:
248 raise ConfigurationError("There are no routes defined.")
249 if not isinstance(sourcesc, dict):
250 raise ConfigurationError("The 'site/sources' setting must be a dictionary.")
251 if not isinstance(routesc, list):
252 raise ConfigurationError("The 'site/routes' setting must be a list.")
253
254 # Add the theme page source if no sources were defined in the theme
255 # configuration itself.
256 has_any_theme_source = False
257 for sn, sc in sourcesc.iteritems():
258 if sc.get('realm') == REALM_THEME:
259 has_any_theme_source = True
260 break
261 if not has_any_theme_source:
262 sitec['sources']['theme_pages'] = {
263 'theme_source': True,
264 'fs_endpoint': 'pages',
265 'data_endpoint': 'site/pages',
266 'item_name': 'page',
267 'realm': REALM_THEME}
268 sitec['routes'].append({
269 'url': '/%path:path%',
270 'source': 'theme_pages',
271 'func': 'pcurl(path)'})
272
273 # Sources have the `default` scanner by default, duh. Also, a bunch
274 # of other default values for other configuration stuff.
275 for sn, sc in sourcesc.iteritems():
276 if not isinstance(sc, dict):
277 raise ConfigurationError("All sources in 'site/sources' must be dictionaries.")
278 sc.setdefault('type', 'default')
279 sc.setdefault('fs_endpoint', sn)
280 sc.setdefault('data_endpoint', sn)
281 sc.setdefault('data_type', 'iterator')
282 sc.setdefault('item_name', sn)
283 sc.setdefault('items_per_page', 5)
284 sc.setdefault('date_format', DEFAULT_DATE_FORMAT)
285 sc.setdefault('realm', REALM_USER)
286
287 # Check routes are referencing correct routes, have default
288 # values, etc.
289 for rc in routesc:
290 if not isinstance(rc, dict):
291 raise ConfigurationError("All routes in 'site/routes' must be dictionaries.")
292 rc_url = rc.get('url')
293 if not rc_url:
294 raise ConfigurationError("All routes in 'site/routes' must have an 'url'.")
295 if rc_url[0] != '/':
296 raise ConfigurationError("Route URLs must start with '/'.")
297 if rc.get('source') is None:
298 raise ConfigurationError("Routes must specify a source.")
299 if rc['source'] not in sourcesc.keys():
300 raise ConfigurationError("Route is referencing unknown source: %s" %
301 rc['source'])
302 rc.setdefault('taxonomy', None)
303 rc.setdefault('page_suffix', '/%num%')
304
305 # Validate taxonomies.
306 sitec.setdefault('taxonomies', {})
307 taxonomiesc = sitec.get('taxonomies')
308 for tn, tc in taxonomiesc.iteritems():
309 tc.setdefault('multiple', False)
310 tc.setdefault('term', tn)
311 tc.setdefault('page', '_%s.%%ext%%' % tc['term'])
312
313 # Validate endpoints, and make sure the theme has a default source.
314 reserved_endpoints = set(['piecrust', 'site', 'page', 'route',
315 'assets', 'pagination', 'siblings',
316 'family'])
317 for name, src in sitec['sources'].iteritems():
318 endpoint = src['data_endpoint']
319 if endpoint in reserved_endpoints:
320 raise ConfigurationError(
321 "Source '%s' is using a reserved endpoint name: %s" %
322 (name, endpoint))
323
324
325 # Done validating!
326 return values
97 327
98 328
99 class PieCrust(object): 329 class PieCrust(object):
100 def __init__(self, root, cache=True, debug=False, env=None): 330 def __init__(self, root_dir, cache=True, debug=False, theme_site=False,
101 self.root = root 331 env=None):
332 self.root_dir = root_dir
102 self.debug = debug 333 self.debug = debug
103 self.cache = cache 334 self.theme_site = theme_site
104 self.plugin_loader = PluginLoader(self) 335 self.plugin_loader = PluginLoader(self)
336
337 if cache:
338 self.cache = ExtensibleCache(self.cache_dir)
339 else:
340 self.cache = NullExtensibleCache()
341
105 self.env = env 342 self.env = env
106 if self.env is None: 343 if self.env is None:
107 self.env = StandardEnvironment() 344 self.env = StandardEnvironment()
108 self.env.initialize(self) 345 self.env.initialize(self)
109 346
110 @lazy_property 347 @cached_property
111 def config(self): 348 def config(self):
112 logger.debug("Loading site configuration...") 349 logger.debug("Creating site configuration...")
113 paths = [] 350 paths = []
114 if self.theme_dir: 351 if self.theme_dir:
115 paths.append(os.path.join(self.theme_dir, THEME_CONFIG_PATH)) 352 paths.append(os.path.join(self.theme_dir, THEME_CONFIG_PATH))
116 paths.append(os.path.join(self.root, CONFIG_PATH)) 353 paths.append(os.path.join(self.root_dir, CONFIG_PATH))
117 354
118 config = PieCrustConfiguration(paths, self.cache_dir) 355 config_cache = self.cache.getCache('app')
356 config = PieCrustConfiguration(paths, config_cache)
119 if self.theme_dir: 357 if self.theme_dir:
120 # We'll need to patch the templates directories to be relative 358 # We'll need to patch the templates directories to be relative
121 # to the site's root, and not the theme root. 359 # to the site's root, and not the theme root.
122 def _fixupThemeTemplatesDir(index, config): 360 def _fixupThemeTemplatesDir(index, config):
123 if index == 0: 361 if index != 0:
124 sitec = config.get('site') 362 return
125 if sitec: 363 sitec = config.get('site')
126 tplc = sitec.get('templates_dirs') 364 if sitec is None:
127 if tplc: 365 return
128 if isinstance(tplc, types.StringTypes): 366 tplc = sitec.get('templates_dirs')
129 tplc = [tplc] 367 if tplc is None:
130 sitec['templates_dirs'] = filter(tplc, 368 return
131 lambda p: os.path.join(self.theme_dir, p)) 369 if isinstance(tplc, types.StringTypes):
132 370 tplc = [tplc]
371 sitec['templates_dirs'] = filter(tplc,
372 lambda p: os.path.join(self.theme_dir, p))
133 config.fixups.append(_fixupThemeTemplatesDir) 373 config.fixups.append(_fixupThemeTemplatesDir)
134 374
375 # We'll also need to flag all page sources as coming from
376 # the theme.
377 def _fixupThemeSources(index, config):
378 if index != 0:
379 return
380 sitec = config.get('site')
381 if sitec is None:
382 sitec = {}
383 config['site'] = sitec
384 srcc = sitec.get('sources')
385 if srcc is not None:
386 for sn, sc in srcc.iteritems():
387 sc['realm'] = REALM_THEME
388 config.fixups.append(_fixupThemeSources)
389
135 return config 390 return config
136 391
137 @lazy_property 392 @cached_property
138 def templates_dirs(self): 393 def templates_dirs(self):
139 templates_dirs = self._get_configurable_dirs(TEMPLATES_DIR, 394 templates_dirs = self._get_configurable_dirs(TEMPLATES_DIR,
140 'site/templates_dirs') 395 'site/templates_dirs')
141 396
142 # Also, add the theme directory, if nay. 397 # Also, add the theme directory, if nay.
145 if os.path.isdir(default_theme_dir): 400 if os.path.isdir(default_theme_dir):
146 templates_dirs.append(default_theme_dir) 401 templates_dirs.append(default_theme_dir)
147 402
148 return templates_dirs 403 return templates_dirs
149 404
150 @lazy_property 405 @cached_property
151 def pages_dir(self):
152 return self._get_dir(PAGES_DIR)
153
154 @lazy_property
155 def posts_dir(self):
156 return self._get_dir(POSTS_DIR)
157
158 @lazy_property
159 def plugins_dirs(self): 406 def plugins_dirs(self):
160 return self._get_configurable_dirs(PLUGINS_DIR, 407 return self._get_configurable_dirs(PLUGINS_DIR,
161 'site/plugins_dirs') 408 'site/plugins_dirs')
162 409
163 @lazy_property 410 @cached_property
164 def theme_dir(self): 411 def theme_dir(self):
165 return self._get_dir(THEME_DIR) 412 td = self._get_dir(THEME_DIR)
166 413 if td is not None:
167 @lazy_property 414 return td
415 return os.path.join(os.path.dirname(__file__), 'resources', 'theme')
416
417 @cached_property
168 def cache_dir(self): 418 def cache_dir(self):
169 if self.cache: 419 return os.path.join(self.root_dir, CACHE_DIR)
170 return os.path.join(self.root, CACHE_DIR) 420
171 return False 421 @cached_property
422 def sources(self):
423 defs = {}
424 for cls in self.plugin_loader.getSources():
425 defs[cls.SOURCE_NAME] = cls
426
427 sources = []
428 for n, s in self.config.get('site/sources').iteritems():
429 cls = defs.get(s['type'])
430 if cls is None:
431 raise ConfigurationError("No such page source type: %s" % s['type'])
432 src = cls(self, n, s)
433 sources.append(src)
434 return sources
435
436 @cached_property
437 def routes(self):
438 routes = []
439 for r in self.config.get('site/routes'):
440 rte = Route(self, r)
441 routes.append(rte)
442 return routes
443
444 @cached_property
445 def taxonomies(self):
446 taxonomies = []
447 for tn, tc in self.config.get('site/taxonomies').iteritems():
448 tax = Taxonomy(self, tn, tc)
449 taxonomies.append(tax)
450 return taxonomies
451
452 def getSource(self, source_name):
453 for source in self.sources:
454 if source.name == source_name:
455 return source
456 return None
457
458 def getRoutes(self, source_name, skip_taxonomies=False):
459 for route in self.routes:
460 if route.source_name == source_name:
461 if not skip_taxonomies or route.taxonomy is None:
462 yield route
463
464 def getRoute(self, source_name, source_metadata):
465 for route in self.getRoutes(source_name, True):
466 if route.isMatch(source_metadata):
467 return route
468 return None
469
470 def getTaxonomyRoute(self, tax_name, source_name):
471 for route in self.routes:
472 if route.taxonomy == tax_name and route.source_name == source_name:
473 return route
474 return None
475
476 def getTaxonomy(self, tax_name):
477 for tax in self.taxonomies:
478 if tax.name == tax_name:
479 return tax
480 return None
172 481
173 def _get_dir(self, default_rel_dir): 482 def _get_dir(self, default_rel_dir):
174 abs_dir = os.path.join(self.root, default_rel_dir) 483 abs_dir = os.path.join(self.root_dir, default_rel_dir)
175 if os.path.isdir(abs_dir): 484 if os.path.isdir(abs_dir):
176 return abs_dir 485 return abs_dir
177 return False 486 return None
178 487
179 def _get_configurable_dirs(self, default_rel_dir, conf_name): 488 def _get_configurable_dirs(self, default_rel_dir, conf_name):
180 dirs = [] 489 dirs = []
181 490
182 # Add custom directories from the configuration. 491 # Add custom directories from the configuration.
183 conf_dirs = self.config.get(conf_name) 492 conf_dirs = self.config.get(conf_name)
184 if conf_dirs is not None: 493 if conf_dirs is not None:
185 dirs += filter(conf_dirs, 494 if isinstance(conf_dirs, types.StringTypes):
186 lambda p: os.path.join(self.root, p)) 495 dirs.append(os.path.join(self.root_dir, conf_dirs))
496 else:
497 dirs += filter(lambda p: os.path.join(self.root_dir, p),
498 conf_dirs)
187 499
188 # Add the default directory if it exists. 500 # Add the default directory if it exists.
189 default_dir = os.path.join(self.root, default_rel_dir) 501 default_dir = os.path.join(self.root_dir, default_rel_dir)
190 if os.path.isdir(default_dir): 502 if os.path.isdir(default_dir):
191 dirs.append(default_dir) 503 dirs.append(default_dir)
192 504
193 return dirs 505 return dirs
194 506