Mercurial > piecrust2
changeset 465:b6e797463798
Merge pull request #19 from GitHub.
author | Ludovic Chabant <ludovic@chabant.com> |
---|---|
date | Sat, 11 Jul 2015 20:33:55 -0700 |
parents | aefd2714d205 (diff) 375301e024b5 (current diff) |
children | 456db44dcc53 |
files | |
diffstat | 85 files changed, 4120 insertions(+), 2268 deletions(-) [+] |
line wrap: on
line diff
--- a/.hgignore Thu Jun 25 05:51:47 2015 +1000 +++ b/.hgignore Sat Jul 11 20:33:55 2015 -0700 @@ -3,8 +3,8 @@ venv tags build/lib -build/messages/_cache -build/messages/_counter +util/messages/_cache +util/messages/_counter dist docs/_cache docs/_counter @@ -13,3 +13,4 @@ piecrust.egg-info piecrust/__version__.py +docs
--- a/.travis.yml Thu Jun 25 05:51:47 2015 +1000 +++ b/.travis.yml Sat Jul 11 20:33:55 2015 -0700 @@ -1,3 +1,4 @@ +sudo: false language: python python: - "3.4"
--- a/build/generate_messages.cmd Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,11 +0,0 @@ -@echo off -setlocal - -set CUR_DIR=%~dp0 -set CHEF=%CUR_DIR%..\bin\chef -set OUT_DIR=%CUR_DIR%..\piecrust\resources\messages -set ROOT_DIR=%CUR_DIR%messages - -%CHEF% --root=%ROOT_DIR% bake -o %OUT_DIR% -del %OUT_DIR%\index.html -
--- a/build/generate_messages.sh Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,9 +0,0 @@ -#!/bin/sh - -CUR_DIR="$( cd "$( dirname "$0" )" && pwd )" -CHEF=${CUR_DIR}/../bin/chef -OUT_DIR=${CUR_DIR}/../piecrust/resources/messages -ROOT_DIR=${CUR_DIR}/messages - -$CHEF --root=$ROOT_DIR bake -o $OUT_DIR -rm ${OUT_DIR}/index.html
--- a/build/messages/config.yml Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,2 +0,0 @@ -site: - title: PieCrust System Messages
--- a/build/messages/pages/_index.html Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,12 +0,0 @@ ---- -title: PieCrust System Messages ---- - -Here are the **PieCrust** system message pages: - -* [Requirements Not Met]({{ pcurl('requirements') }}) -* [Error]({{ pcurl('error') }}) -* [Not Found]({{ pcurl('error404') }}) -* [Critical Error]({{ pcurl('critical') }}) - -This very page you're reading, however, is only here for convenience.
--- a/build/messages/pages/critical.html Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,6 +0,0 @@ ---- -title: The Whole Kitchen Burned Down! -layout: error ---- -Something critically bad happened, and **PieCrust** needs to shut down. It's probably our fault. -
--- a/build/messages/pages/error.html Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,5 +0,0 @@ ---- -title: The Cake Just Burned! -layout: error ---- -
--- a/build/messages/pages/error404.html Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,6 +0,0 @@ ---- -title: Can't find the sugar! -layout: error ---- -It looks like the page you were trying to access does not exist around here. Try going somewhere else. -
--- a/build/messages/templates/default.html Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,70 +0,0 @@ -<!doctype html> -<html> -<head> - <title>{{ page.title }}</title> - <meta name="generator" content="PieCrust" /> - <link rel="stylesheet" type="text/css" href="http://fonts.googleapis.com/css?family=Lobster"> - <style> - body { - margin: 0; - padding: 1em; - background: #eee; - color: #000; - font-family: Georgia, serif; - } - h1 { - font-size: 4.5em; - font-family: Lobster, 'Trebuchet MS', Verdana, sans-serif; - text-align: center; - font-weight: bold; - margin-top: 0; - color: #333; - text-shadow: 0px 2px 5px rgba(0,0,0,0.3); - } - h2 { - font-size: 2.5em; - font-family: 'Lobster', 'Trebuchet MS', Verdana, sans-serif; - } - code { - background: #ddd; - padding: 0 0.2em; - } - #preamble { - font-size: 1.2em; - font-style: italic; - text-align: center; - margin-bottom: 0; - } - #container { - margin: 0 20%; - } - #content { - margin: 2em 1em; - } - .error-details { - color: #d11; - } - .note { - margin: 3em; - color: #888; - font-style: italic; - } - </style> -</head> -<body> - <div id="container"> - <div id="header"> - <p id="preamble">A Message From The Kitchen:</p> - <h1>{{ page.title }}</h1> - </div> - <hr /> - <div id="content"> - {% block content %} - {{ content|safe }} - {% endblock %} - </div> - <hr /> - {% block footer %}{% endblock %} - </div> -</body> -</html>
--- a/build/messages/templates/error.html Thu Jun 25 05:51:47 2015 +1000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,27 +0,0 @@ -{% extends "default.html" %} - -{% block content %} -{{content|safe}} - -{# The following is `raw` because we want it to be in the - produced page, so it can then be templated on the fly - with the error messages #} -{% raw %} -{% if details %} -<div class="error-details"> - <p>Error details:</p> - <ul> - {% for desc in details %} - <li>{{ desc }}</li> - {% endfor %} - </ul> -</div> -{% endif %} -{% endraw %} -{% endblock %} - -{% block footer %} -{% pcformat 'textile' %} -p(note). You're seeing this because something wrong happend. To see detailed errors with callstacks, run chef with the @--debug@ parameter, append @?!debug@ to the URL, or initialize the @PieCrust@ object with @{'debug': true}@. On the other hand, to see you custom error pages, set the @site/display_errors@ setting to @false@. -{% endpcformat %} -{% endblock %}
--- a/piecrust/__init__.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/__init__.py Sat Jul 11 20:33:55 2015 -0700 @@ -7,6 +7,7 @@ CONFIG_PATH = 'config.yml' THEME_CONFIG_PATH = 'theme_config.yml' THEME_INFO_PATH = 'theme_info.yml' +ASSET_DIR_SUFFIX = '-assets' DEFAULT_FORMAT = 'markdown' DEFAULT_TEMPLATE_ENGINE = 'jinja2'
--- a/piecrust/app.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/app.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,5 +1,6 @@ import re import json +import time import os.path import codecs import hashlib @@ -28,7 +29,7 @@ logger = logging.getLogger(__name__) -CACHE_VERSION = 19 +CACHE_VERSION = 20 class VariantNotFoundError(Exception): @@ -412,10 +413,15 @@ if self.env is None: self.env = StandardEnvironment() self.env.initialize(self) + self.env.registerTimer('SiteConfigLoad') + self.env.registerTimer('PageLoad') + self.env.registerTimer("PageDataBuild") @cached_property def config(self): logger.debug("Creating site configuration...") + start_time = time.perf_counter() + paths = [] if self.theme_dir: paths.append(os.path.join(self.theme_dir, THEME_CONFIG_PATH)) @@ -456,6 +462,7 @@ sc['realm'] = REALM_THEME config.fixups.append(_fixupThemeSources) + self.env.stepTimer('SiteConfigLoad', time.perf_counter() - start_time) return config @cached_property
--- a/piecrust/baking/baker.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/baking/baker.py Sat Jul 11 20:33:55 2015 -0700 @@ -2,12 +2,15 @@ import os.path import hashlib import logging -import threading +import multiprocessing from piecrust.baking.records import ( - TransitionalBakeRecord, BakeRecordPageEntry) -from piecrust.baking.scheduler import BakeScheduler -from piecrust.baking.single import (BakingError, PageBaker) -from piecrust.chefutil import format_timed, log_friendly_exception + BakeRecordEntry, TransitionalBakeRecord, TaxonomyInfo) +from piecrust.baking.worker import ( + save_factory, + JOB_LOAD, JOB_RENDER_FIRST, JOB_BAKE) +from piecrust.chefutil import ( + format_timed_scope, format_timed) +from piecrust.routing import create_route_metadata from piecrust.sources.base import ( REALM_NAMES, REALM_USER, REALM_THEME) @@ -21,7 +24,6 @@ self.app = app self.out_dir = out_dir self.force = force - self.num_workers = app.config.get('baker/workers', 4) # Remember what taxonomy pages we should skip # (we'll bake them repeatedly later with each taxonomy term) @@ -29,17 +31,23 @@ logger.debug("Gathering taxonomy page paths:") for tax in self.app.taxonomies: for src in self.app.sources: - path = tax.resolvePagePath(src.name) - if path is not None: + tax_page_ref = tax.getPageRef(src) + for path in tax_page_ref.possible_paths: self.taxonomy_pages.append(path) logger.debug(" - %s" % path) + # Register some timers. + self.app.env.registerTimer('LoadJob', raise_if_registered=False) + self.app.env.registerTimer('RenderFirstSubJob', + raise_if_registered=False) + self.app.env.registerTimer('BakeJob', raise_if_registered=False) + def bake(self): logger.debug(" Bake Output: %s" % self.out_dir) logger.debug(" Root URL: %s" % self.app.config.get('site/root')) # Get into bake mode. - start_time = time.clock() + start_time = time.perf_counter() self.app.config.set('baker/is_baking', True) self.app.env.base_asset_url_format = '%uri%' @@ -52,35 +60,60 @@ record_cache = self.app.cache.getCache('baker') record_id = hashlib.md5(self.out_dir.encode('utf8')).hexdigest() record_name = record_id + '.record' + previous_record_path = None if not self.force and record_cache.has(record_name): - t = time.clock() - record.loadPrevious(record_cache.getCachePath(record_name)) - logger.debug(format_timed( - t, 'loaded previous bake record', - colored=False)) + with format_timed_scope(logger, "loaded previous bake record", + level=logging.DEBUG, colored=False): + previous_record_path = record_cache.getCachePath(record_name) + record.loadPrevious(previous_record_path) record.current.success = True # Figure out if we need to clean the cache because important things # have changed. - self._handleCacheValidity(record) + is_cache_valid = self._handleCacheValidity(record) + if not is_cache_valid: + previous_record_path = None + + # Pre-create all caches. + for cache_name in ['app', 'baker', 'pages', 'renders']: + self.app.cache.getCache(cache_name) # Gather all sources by realm -- we're going to bake each realm - # separately so we can handle "overlaying" (i.e. one realm overrides - # another realm's pages). + # separately so we can handle "overriding" (i.e. one realm overrides + # another realm's pages, like the user realm overriding the theme + # realm). sources_by_realm = {} for source in self.app.sources: srclist = sources_by_realm.setdefault(source.realm, []) srclist.append(source) + # Create the worker processes. + pool = self._createWorkerPool(previous_record_path) + # Bake the realms. realm_list = [REALM_USER, REALM_THEME] for realm in realm_list: srclist = sources_by_realm.get(realm) if srclist is not None: - self._bakeRealm(record, realm, srclist) + self._bakeRealm(record, pool, realm, srclist) # Bake taxonomies. - self._bakeTaxonomies(record) + self._bakeTaxonomies(record, pool) + + # All done with the workers. Close the pool and get timing reports. + reports = pool.close() + record.current.timers = {} + for i in range(len(reports)): + timers = reports[i] + if timers is None: + continue + + worker_name = 'BakeWorker_%d' % i + record.current.timers[worker_name] = {} + for name, val in timers['data'].items(): + main_val = record.current.timers.setdefault(name, 0) + record.current.timers[name] = main_val + val + record.current.timers[worker_name][name] = val # Delete files from the output. self._handleDeletetions(record) @@ -98,11 +131,11 @@ os.rename(record_path, record_path_next) # Save the bake record. - t = time.clock() - record.current.bake_time = time.time() - record.current.out_dir = self.out_dir - record.saveCurrent(record_cache.getCachePath(record_name)) - logger.debug(format_timed(t, 'saved bake record', colored=False)) + with format_timed_scope(logger, "saved bake record.", + level=logging.DEBUG, colored=False): + record.current.bake_time = time.time() + record.current.out_dir = self.out_dir + record.saveCurrent(record_cache.getCachePath(record_name)) # All done. self.app.config.set('baker/is_baking', False) @@ -111,7 +144,7 @@ return record.detach() def _handleCacheValidity(self, record): - start_time = time.clock() + start_time = time.perf_counter() reason = None if self.force: @@ -146,47 +179,152 @@ logger.info(format_timed( start_time, "cleaned cache (reason: %s)" % reason)) + return False else: record.incremental_count += 1 logger.debug(format_timed( start_time, "cache is assumed valid", colored=False)) + return True - def _bakeRealm(self, record, realm, srclist): - # Gather all page factories from the sources and queue them - # for the workers to pick up. Just skip taxonomy pages for now. - logger.debug("Baking realm %s" % REALM_NAMES[realm]) - pool, queue, abort = self._createWorkerPool(record, self.num_workers) + def _bakeRealm(self, record, pool, realm, srclist): + start_time = time.perf_counter() + try: + record.current.baked_count[realm] = 0 + + all_factories = [] + for source in srclist: + factories = source.getPageFactories() + all_factories += [f for f in factories + if f.path not in self.taxonomy_pages] + + self._loadRealmPages(record, pool, all_factories) + self._renderRealmPages(record, pool, all_factories) + self._bakeRealmPages(record, pool, realm, all_factories) + finally: + page_count = record.current.baked_count[realm] + logger.info(format_timed( + start_time, + "baked %d %s pages." % + (page_count, REALM_NAMES[realm].lower()))) - for source in srclist: - factories = source.getPageFactories() + def _loadRealmPages(self, record, pool, factories): + def _handler(res): + # Create the record entry for this page. + # This will also update the `dirty_source_names` for the record + # as we add page files whose last modification times are later + # than the last bake. + record_entry = BakeRecordEntry(res['source_name'], res['path']) + record_entry.config = res['config'] + if res['errors']: + record_entry.errors += res['errors'] + record.current.success = False + self._logErrors(res['path'], res['errors']) + record.addEntry(record_entry) + + logger.debug("Loading %d realm pages..." % len(factories)) + with format_timed_scope(logger, + "loaded %d pages" % len(factories), + level=logging.DEBUG, colored=False, + timer_env=self.app.env, + timer_category='LoadJob'): + jobs = [] for fac in factories: - if fac.path in self.taxonomy_pages: - logger.debug( - "Skipping taxonomy page: %s:%s" % - (source.name, fac.ref_spec)) + job = { + 'type': JOB_LOAD, + 'job': save_factory(fac)} + jobs.append(job) + ar = pool.queueJobs(jobs, handler=_handler) + ar.wait() + + def _renderRealmPages(self, record, pool, factories): + def _handler(res): + entry = record.getCurrentEntry(res['path']) + if res['errors']: + entry.errors += res['errors'] + record.current.success = False + self._logErrors(res['path'], res['errors']) + + logger.debug("Rendering %d realm pages..." % len(factories)) + with format_timed_scope(logger, + "prepared %d pages" % len(factories), + level=logging.DEBUG, colored=False, + timer_env=self.app.env, + timer_category='RenderFirstSubJob'): + jobs = [] + for fac in factories: + record_entry = record.getCurrentEntry(fac.path) + if record_entry.errors: + logger.debug("Ignoring %s because it had previous " + "errors." % fac.ref_spec) continue - entry = BakeRecordPageEntry(fac.source.name, fac.rel_path, - fac.path) - record.addEntry(entry) + # Make sure the source and the route exist for this page, + # otherwise we add errors to the record entry and we'll skip + # this page for the rest of the bake. + source = self.app.getSource(fac.source.name) + if source is None: + record_entry.errors.append( + "Can't get source for page: %s" % fac.ref_spec) + logger.error(record_entry.errors[-1]) + continue - route = self.app.getRoute(source.name, fac.metadata, + route = self.app.getRoute(fac.source.name, fac.metadata, skip_taxonomies=True) if route is None: - entry.errors.append( + record_entry.errors.append( "Can't get route for page: %s" % fac.ref_spec) - logger.error(entry.errors[-1]) + logger.error(record_entry.errors[-1]) continue - queue.addJob(BakeWorkerJob(fac, route, entry)) + # All good, queue the job. + job = { + 'type': JOB_RENDER_FIRST, + 'job': save_factory(fac)} + jobs.append(job) + + ar = pool.queueJobs(jobs, handler=_handler) + ar.wait() + + def _bakeRealmPages(self, record, pool, realm, factories): + def _handler(res): + entry = record.getCurrentEntry(res['path'], res['taxonomy_info']) + entry.subs = res['sub_entries'] + if res['errors']: + entry.errors += res['errors'] + self._logErrors(res['path'], res['errors']) + if entry.has_any_error: + record.current.success = False + if entry.subs and entry.was_any_sub_baked: + record.current.baked_count[realm] += 1 - success = self._waitOnWorkerPool(pool, abort) - record.current.success &= success + logger.debug("Baking %d realm pages..." % len(factories)) + with format_timed_scope(logger, + "baked %d pages" % len(factories), + level=logging.DEBUG, colored=False, + timer_env=self.app.env, + timer_category='BakeJob'): + jobs = [] + for fac in factories: + job = self._makeBakeJob(record, fac) + if job is not None: + jobs.append(job) - def _bakeTaxonomies(self, record): - logger.debug("Baking taxonomies") + ar = pool.queueJobs(jobs, handler=_handler) + ar.wait() + def _bakeTaxonomies(self, record, pool): + logger.debug("Baking taxonomy pages...") + with format_timed_scope(logger, 'built taxonomy buckets', + level=logging.DEBUG, colored=False): + buckets = self._buildTaxonomyBuckets(record) + + start_time = time.perf_counter() + page_count = self._bakeTaxonomyBuckets(record, pool, buckets) + logger.info(format_timed(start_time, + "baked %d taxonomy pages." % page_count)) + + def _buildTaxonomyBuckets(self, record): # Let's see all the taxonomy terms for which we must bake a # listing page... first, pre-populate our big map of used terms. # For each source name, we have a list of taxonomies, and for each @@ -250,8 +388,19 @@ if not tt_info.dirty_terms.isdisjoint(set(terms)): tt_info.dirty_terms.add(terms) + return buckets + + def _bakeTaxonomyBuckets(self, record, pool, buckets): + def _handler(res): + entry = record.getCurrentEntry(res['path'], res['taxonomy_info']) + entry.subs = res['sub_entries'] + if res['errors']: + entry.errors += res['errors'] + if entry.has_any_error: + record.current.success = False + # Start baking those terms. - pool, queue, abort = self._createWorkerPool(record, self.num_workers) + jobs = [] for source_name, source_taxonomies in buckets.items(): for tax_name, tt_info in source_taxonomies.items(): terms = tt_info.dirty_terms @@ -262,8 +411,8 @@ "Baking '%s' for source '%s': %s" % (tax_name, source_name, terms)) tax = self.app.getTaxonomy(tax_name) - route = self.app.getTaxonomyRoute(tax_name, source_name) - tax_page_ref = tax.getPageRef(source_name) + source = self.app.getSource(source_name) + tax_page_ref = tax.getPageRef(source) if not tax_page_ref.exists: logger.debug( "No taxonomy page found at '%s', skipping." % @@ -273,19 +422,24 @@ logger.debug( "Using taxonomy page: %s:%s" % (tax_page_ref.source_name, tax_page_ref.rel_path)) + fac = tax_page_ref.getFactory() + for term in terms: - fac = tax_page_ref.getFactory() logger.debug( "Queuing: %s [%s=%s]" % (fac.ref_spec, tax_name, term)) - entry = BakeRecordPageEntry( - fac.source.name, fac.rel_path, fac.path, - (tax_name, term, source_name)) - record.addEntry(entry) - queue.addJob(BakeWorkerJob(fac, route, entry)) + tax_info = TaxonomyInfo(tax_name, source_name, term) + + cur_entry = BakeRecordEntry( + fac.source.name, fac.path, tax_info) + record.addEntry(cur_entry) - success = self._waitOnWorkerPool(pool, abort) - record.current.success &= success + job = self._makeBakeJob(record, fac, tax_info) + if job is not None: + jobs.append(job) + + ar = pool.queueJobs(jobs, handler=_handler) + ar.wait() # Now we create bake entries for all the terms that were *not* dirty. # This is because otherwise, on the next incremental bake, we wouldn't @@ -296,18 +450,76 @@ # current version. if (prev_entry and prev_entry.taxonomy_info and not cur_entry): - sn = prev_entry.source_name - tn, tt, tsn = prev_entry.taxonomy_info - tt_info = buckets[tsn][tn] - if tt in tt_info.all_terms: + ti = prev_entry.taxonomy_info + tt_info = buckets[ti.source_name][ti.taxonomy_name] + if ti.term in tt_info.all_terms: logger.debug("Creating unbaked entry for taxonomy " - "term '%s:%s'." % (tn, tt)) + "term '%s:%s'." % (ti.taxonomy_name, ti.term)) record.collapseEntry(prev_entry) else: logger.debug("Taxonomy term '%s:%s' isn't used anymore." % - (tn, tt)) + (ti.taxonomy_name, ti.term)) + + return len(jobs) + + def _makeBakeJob(self, record, fac, tax_info=None): + # Get the previous (if any) and current entry for this page. + pair = record.getPreviousAndCurrentEntries(fac.path, tax_info) + assert pair is not None + prev_entry, cur_entry = pair + assert cur_entry is not None + + # Ignore if there were errors in the previous passes. + if cur_entry.errors: + logger.debug("Ignoring %s because it had previous " + "errors." % fac.ref_spec) + return None + + # Build the route metadata and find the appropriate route. + page = fac.buildPage() + route_metadata = create_route_metadata(page) + if tax_info is not None: + tax = self.app.getTaxonomy(tax_info.taxonomy_name) + route = self.app.getTaxonomyRoute(tax_info.taxonomy_name, + tax_info.source_name) + + slugified_term = route.slugifyTaxonomyTerm(tax_info.term) + route_metadata[tax.term_name] = slugified_term + else: + route = self.app.getRoute(fac.source.name, route_metadata, + skip_taxonomies=True) + assert route is not None + + # Figure out if this page is overriden by another previously + # baked page. This happens for example when the user has + # made a page that has the same page/URL as a theme page. + uri = route.getUri(route_metadata) + override_entry = record.getOverrideEntry(page.path, uri) + if override_entry is not None: + override_source = self.app.getSource( + override_entry.source_name) + if override_source.realm == fac.source.realm: + cur_entry.errors.append( + "Page '%s' maps to URL '%s' but is overriden " + "by page '%s'." % + (fac.ref_spec, uri, override_entry.path)) + logger.error(cur_entry.errors[-1]) + cur_entry.flags |= BakeRecordEntry.FLAG_OVERRIDEN + return None + + job = { + 'type': JOB_BAKE, + 'job': { + 'factory_info': save_factory(fac), + 'taxonomy_info': tax_info, + 'route_metadata': route_metadata, + 'dirty_source_names': record.dirty_source_names + } + } + return job def _handleDeletetions(self, record): + logger.debug("Handling deletions...") for path, reason in record.getDeletions(): logger.debug("Removing '%s': %s" % (path, reason)) try: @@ -318,139 +530,29 @@ # by the user. pass - def _createWorkerPool(self, record, pool_size=4): - pool = [] - queue = BakeScheduler(record) - abort = threading.Event() - for i in range(pool_size): - ctx = BakeWorkerContext( - self.app, self.out_dir, self.force, - record, queue, abort) - worker = BakeWorker(i, ctx) - pool.append(worker) - return pool, queue, abort - - def _waitOnWorkerPool(self, pool, abort): - for w in pool: - w.start() - - success = True - try: - for w in pool: - w.join() - success &= w.success - except KeyboardInterrupt: - logger.warning("Bake aborted by user... " - "waiting for workers to stop.") - abort.set() - for w in pool: - w.join() - raise + def _logErrors(self, path, errors): + rel_path = os.path.relpath(path, self.app.root_dir) + logger.error("Errors found in %s:" % rel_path) + for e in errors: + logger.error(" " + e) - if abort.is_set(): - excs = [w.abort_exception for w in pool - if w.abort_exception is not None] - logger.error("Baking was aborted due to %s error(s):" % len(excs)) - if self.app.debug: - for e in excs: - logger.exception(e) - else: - for e in excs: - log_friendly_exception(logger, e) - raise BakingError("Baking was aborted due to errors.") - - return success - - -class BakeWorkerContext(object): - def __init__(self, app, out_dir, force, record, work_queue, - abort_event): - self.app = app - self.out_dir = out_dir - self.force = force - self.record = record - self.work_queue = work_queue - self.abort_event = abort_event - - -class BakeWorkerJob(object): - def __init__(self, factory, route, record_entry): - self.factory = factory - self.route = route - self.record_entry = record_entry - - @property - def source(self): - return self.factory.source - + def _createWorkerPool(self, previous_record_path): + from piecrust.workerpool import WorkerPool + from piecrust.baking.worker import BakeWorkerContext, BakeWorker -class BakeWorker(threading.Thread): - def __init__(self, wid, ctx): - super(BakeWorker, self).__init__(name=('worker%d' % wid)) - self.wid = wid - self.ctx = ctx - self.abort_exception = None - self.success = True - self._page_baker = PageBaker( - ctx.app, ctx.out_dir, ctx.force, - ctx.record) - - def run(self): - while(not self.ctx.abort_event.is_set()): - try: - job = self.ctx.work_queue.getNextJob(wait_timeout=1) - if job is None: - logger.debug( - "[%d] No more work... shutting down." % - self.wid) - break - success = self._unsafeRun(job) - logger.debug("[%d] Done with page." % self.wid) - self.ctx.work_queue.onJobFinished(job) - self.success &= success - except Exception as ex: - self.ctx.abort_event.set() - self.abort_exception = ex - self.success = False - logger.debug("[%d] Critical error, aborting." % self.wid) - if self.ctx.app.debug: - logger.exception(ex) - break + worker_count = self.app.config.get('baker/workers') + batch_size = self.app.config.get('baker/batch_size') - def _unsafeRun(self, job): - start_time = time.clock() - - entry = job.record_entry - try: - self._page_baker.bake(job.factory, job.route, entry) - except BakingError as ex: - logger.debug("Got baking error. Adding it to the record.") - while ex: - entry.errors.append(str(ex)) - ex = ex.__cause__ - - has_error = False - for e in entry.getAllErrors(): - has_error = True - logger.error(e) - if has_error: - return False - - if entry.was_any_sub_baked: - first_sub = entry.subs[0] - - friendly_uri = first_sub.out_uri - if friendly_uri == '': - friendly_uri = '[main page]' - - friendly_count = '' - if entry.num_subs > 1: - friendly_count = ' (%d pages)' % entry.num_subs - logger.info(format_timed( - start_time, '[%d] %s%s' % - (self.wid, friendly_uri, friendly_count))) - - return True + ctx = BakeWorkerContext( + self.app.root_dir, self.app.cache.base_dir, self.out_dir, + previous_record_path=previous_record_path, + force=self.force, debug=self.app.debug) + pool = WorkerPool( + worker_count=worker_count, + batch_size=batch_size, + worker_class=BakeWorker, + initargs=(ctx,)) + return pool class _TaxonomyTermsInfo(object): @@ -463,3 +565,4 @@ def __repr__(self): return 'dirty:%s, all:%s' % (self.dirty_terms, self.all_terms) +
--- a/piecrust/baking/records.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/baking/records.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,5 +1,6 @@ import copy import os.path +import hashlib import logging from piecrust.records import Record, TransitionalRecord @@ -7,35 +8,31 @@ logger = logging.getLogger(__name__) -def _get_transition_key(source_name, rel_path, taxonomy_info=None): - key = '%s:%s' % (source_name, rel_path) +def _get_transition_key(path, taxonomy_info=None): + key = path if taxonomy_info: - taxonomy_name, taxonomy_term, taxonomy_source_name = taxonomy_info - key += ';%s:%s=' % (taxonomy_source_name, taxonomy_name) - if isinstance(taxonomy_term, tuple): - key += '/'.join(taxonomy_term) + key += '+%s:%s=' % (taxonomy_info.source_name, + taxonomy_info.taxonomy_name) + if isinstance(taxonomy_info.term, tuple): + key += '/'.join(taxonomy_info.term) else: - key += taxonomy_term - return key + key += taxonomy_info.term + return hashlib.md5(key.encode('utf8')).hexdigest() class BakeRecord(Record): - RECORD_VERSION = 12 + RECORD_VERSION = 14 def __init__(self): super(BakeRecord, self).__init__() self.out_dir = None self.bake_time = None + self.baked_count = {} + self.timers = None self.success = True -class BakeRecordPassInfo(object): - def __init__(self): - self.used_source_names = set() - self.used_taxonomy_terms = set() - - -class BakeRecordSubPageEntry(object): +class SubPageBakeInfo(object): FLAG_NONE = 0 FLAG_BAKED = 2**0 FLAG_FORCED_BY_SOURCE = 2**1 @@ -48,7 +45,7 @@ self.out_path = out_path self.flags = self.FLAG_NONE self.errors = [] - self.render_passes = {} + self.render_info = None @property def was_clean(self): @@ -62,13 +59,26 @@ def was_baked_successfully(self): return self.was_baked and len(self.errors) == 0 - def collapseRenderPasses(self, other): - for p, pinfo in self.render_passes.items(): - if p not in other.render_passes: - other.render_passes[p] = copy.deepcopy(pinfo) + def anyPass(self, func): + assert self.render_info is not None + for p, pinfo in self.render_info.items(): + if func(pinfo): + return True + return False + + def copyRenderInfo(self): + assert self.render_info + return copy.deepcopy(self.render_info) -class BakeRecordPageEntry(object): +class TaxonomyInfo(object): + def __init__(self, taxonomy_name, source_name, term): + self.taxonomy_name = taxonomy_name + self.source_name = source_name + self.term = term + + +class BakeRecordEntry(object): """ An entry in the bake record. The `taxonomy_info` attribute should be a tuple of the form: @@ -79,16 +89,14 @@ FLAG_SOURCE_MODIFIED = 2**1 FLAG_OVERRIDEN = 2**2 - def __init__(self, source_name, rel_path, path, taxonomy_info=None): + def __init__(self, source_name, path, taxonomy_info=None): self.source_name = source_name - self.rel_path = rel_path self.path = path self.taxonomy_info = taxonomy_info self.flags = self.FLAG_NONE self.config = None + self.errors = [] self.subs = [] - self.assets = [] - self.errors = [] @property def path_mtime(self): @@ -109,6 +117,20 @@ return True return False + @property + def all_assets(self): + for sub in self.subs: + yield from sub.assets + + @property + def has_any_error(self): + if len(self.errors) > 0: + return True + for o in self.subs: + if len(o.errors) > 0: + return True + return False + def getSub(self, sub_index): return self.subs[sub_index - 1] @@ -120,15 +142,17 @@ def getAllUsedSourceNames(self): res = set() for o in self.subs: - for p, pinfo in o.render_passes.items(): - res |= pinfo.used_source_names + if o.render_info is not None: + for p, pinfo in o.render_info.items(): + res |= pinfo.used_source_names return res def getAllUsedTaxonomyTerms(self): res = set() for o in self.subs: - for p, pinfo in o.render_passes.items(): - res |= pinfo.used_taxonomy_terms + if o.render_info is not None: + for p, pinfo in o.render_info.items(): + res |= pinfo.used_taxonomy_terms return res @@ -141,37 +165,44 @@ def addEntry(self, entry): if (self.previous.bake_time and entry.path_mtime >= self.previous.bake_time): - entry.flags |= BakeRecordPageEntry.FLAG_SOURCE_MODIFIED + entry.flags |= BakeRecordEntry.FLAG_SOURCE_MODIFIED self.dirty_source_names.add(entry.source_name) super(TransitionalBakeRecord, self).addEntry(entry) def getTransitionKey(self, entry): - return _get_transition_key(entry.source_name, entry.rel_path, - entry.taxonomy_info) + return _get_transition_key(entry.path, entry.taxonomy_info) - def getOverrideEntry(self, factory, uri): + def getPreviousAndCurrentEntries(self, path, taxonomy_info=None): + key = _get_transition_key(path, taxonomy_info) + pair = self.transitions.get(key) + return pair + + def getOverrideEntry(self, path, uri): for pair in self.transitions.values(): cur = pair[1] - if (cur and - (cur.source_name != factory.source.name or - cur.rel_path != factory.rel_path)): - for o in cur.subs: - if o.out_uri == uri: - return cur + if cur and cur.path != path: + for o in cur.subs: + if o.out_uri == uri: + return cur return None - def getPreviousEntry(self, source_name, rel_path, taxonomy_info=None): - key = _get_transition_key(source_name, rel_path, taxonomy_info) - pair = self.transitions.get(key) + def getPreviousEntry(self, path, taxonomy_info=None): + pair = self.getPreviousAndCurrentEntries(path, taxonomy_info) if pair is not None: return pair[0] return None + def getCurrentEntry(self, path, taxonomy_info=None): + pair = self.getPreviousAndCurrentEntries(path, taxonomy_info) + if pair is not None: + return pair[1] + return None + def collapseEntry(self, prev_entry): cur_entry = copy.deepcopy(prev_entry) - cur_entry.flags = BakeRecordPageEntry.FLAG_NONE + cur_entry.flags = BakeRecordEntry.FLAG_NONE for o in cur_entry.subs: - o.flags = BakeRecordSubPageEntry.FLAG_NONE + o.flags = SubPageBakeInfo.FLAG_NONE self.addEntry(cur_entry) def getDeletions(self): @@ -187,5 +218,5 @@ yield (p, 'source file changed outputs') def _onNewEntryAdded(self, entry): - entry.flags |= BakeRecordPageEntry.FLAG_NEW + entry.flags |= BakeRecordEntry.FLAG_NEW
--- a/piecrust/baking/single.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/baking/single.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,46 +1,28 @@ import os.path -import copy import shutil import codecs import logging import urllib.parse -from piecrust.baking.records import ( - BakeRecordPassInfo, BakeRecordPageEntry, BakeRecordSubPageEntry) -from piecrust.data.filters import ( - PaginationFilter, HasFilterClause, - IsFilterClause, AndBooleanClause, - page_value_accessor) +from piecrust import ASSET_DIR_SUFFIX +from piecrust.baking.records import SubPageBakeInfo from piecrust.rendering import ( QualifiedPage, PageRenderingContext, render_page, - PASS_FORMATTING, PASS_RENDERING) -from piecrust.sources.base import ( - PageFactory, - REALM_NAMES, REALM_USER, REALM_THEME) + PASS_FORMATTING) from piecrust.uriutil import split_uri logger = logging.getLogger(__name__) -def copy_public_page_config(config): - res = config.getDeepcopy() - for k in list(res.keys()): - if k.startswith('__'): - del res[k] - return res - - class BakingError(Exception): pass class PageBaker(object): - def __init__(self, app, out_dir, force=False, record=None, - copy_assets=True): + def __init__(self, app, out_dir, force=False, copy_assets=True): self.app = app self.out_dir = out_dir self.force = force - self.record = record self.copy_assets = copy_assets self.site_root = app.config.get('site/root') self.pretty_urls = app.config.get('site/pretty_urls') @@ -60,124 +42,41 @@ return os.path.normpath(os.path.join(*bake_path)) - def bake(self, factory, route, record_entry): - # Get the page. - page = factory.buildPage() - route_metadata = copy.deepcopy(factory.metadata) - - # Add taxonomy info in the template data and route metadata if needed. - bake_taxonomy_info = None - if record_entry.taxonomy_info: - tax_name, tax_term, tax_source_name = record_entry.taxonomy_info - taxonomy = self.app.getTaxonomy(tax_name) - slugified_term = route.slugifyTaxonomyTerm(tax_term) - route_metadata[taxonomy.term_name] = slugified_term - bake_taxonomy_info = (taxonomy, tax_term) - - # Generate the URI. - uri = route.getUri(route_metadata, provider=page) - - # See if this URL has been overriden by a previously baked page. - # If that page is from another realm (e.g. a user page vs. a theme - # page), we silently skip this page. If they're from the same realm, - # we don't allow overriding and raise an error (this is probably - # because of a misconfigured configuration that allows for ambiguous - # URLs between 2 routes or sources). - override = self.record.getOverrideEntry(factory, uri) - if override is not None: - override_source = self.app.getSource(override.source_name) - if override_source.realm == factory.source.realm: - raise BakingError( - "Page '%s' maps to URL '%s' but is overriden by page" - "'%s:%s'." % (factory.ref_spec, uri, - override.source_name, - override.rel_path)) - logger.debug("'%s' [%s] is overriden by '%s:%s'. Skipping" % - (factory.ref_spec, uri, override.source_name, - override.rel_path)) - record_entry.flags |= BakeRecordPageEntry.FLAG_OVERRIDEN - return - - # Setup the record entry. - record_entry.config = copy_public_page_config(page.config) - + def bake(self, qualified_page, prev_entry, dirty_source_names, + tax_info=None): # Start baking the sub-pages. cur_sub = 1 has_more_subs = True - force_this = self.force - invalidate_formatting = False - prev_record_entry = self.record.getPreviousEntry( - factory.source.name, factory.rel_path, - record_entry.taxonomy_info) - - logger.debug("Baking '%s'..." % uri) + sub_entries = [] while has_more_subs: # Get the URL and path for this sub-page. - sub_uri = route.getUri(route_metadata, sub_num=cur_sub, - provider=page) + sub_uri = qualified_page.getUri(cur_sub) + logger.debug("Baking '%s' [%d]..." % (sub_uri, cur_sub)) out_path = self.getOutputPath(sub_uri) # Create the sub-entry for the bake record. - record_sub_entry = BakeRecordSubPageEntry(sub_uri, out_path) - record_entry.subs.append(record_sub_entry) + sub_entry = SubPageBakeInfo(sub_uri, out_path) + sub_entries.append(sub_entry) # Find a corresponding sub-entry in the previous bake record. - prev_record_sub_entry = None - if prev_record_entry: + prev_sub_entry = None + if prev_entry: try: - prev_record_sub_entry = prev_record_entry.getSub(cur_sub) + prev_sub_entry = prev_entry.getSub(cur_sub) except IndexError: pass - # Figure out what to do with this page. - if (prev_record_sub_entry and - (prev_record_sub_entry.was_baked_successfully or - prev_record_sub_entry.was_clean)): - # If the current page is known to use pages from other sources, - # see if any of those got baked, or are going to be baked for - # some reason. If so, we need to bake this one too. - # (this happens for instance with the main page of a blog). - dirty_src_names, invalidated_render_passes = ( - self._getDirtySourceNamesAndRenderPasses( - prev_record_sub_entry)) - if len(invalidated_render_passes) > 0: - logger.debug( - "'%s' is known to use sources %s, which have " - "items that got (re)baked. Will force bake this " - "page. " % (uri, dirty_src_names)) - record_sub_entry.flags |= \ - BakeRecordSubPageEntry.FLAG_FORCED_BY_SOURCE - force_this = True - - if PASS_FORMATTING in invalidated_render_passes: - logger.debug( - "Will invalidate cached formatting for '%s' " - "since sources were using during that pass." - % uri) - invalidate_formatting = True - elif (prev_record_sub_entry and - prev_record_sub_entry.errors): - # Previous bake failed. We'll have to bake it again. - logger.debug( - "Previous record entry indicates baking failed for " - "'%s'. Will bake it again." % uri) - record_sub_entry.flags |= \ - BakeRecordSubPageEntry.FLAG_FORCED_BY_PREVIOUS_ERRORS - force_this = True - elif not prev_record_sub_entry: - # No previous record. We'll have to bake it. - logger.debug("No previous record entry found for '%s'. Will " - "force bake it." % uri) - record_sub_entry.flags |= \ - BakeRecordSubPageEntry.FLAG_FORCED_BY_NO_PREVIOUS - force_this = True + # Figure out if we need to invalidate or force anything. + force_this_sub, invalidate_formatting = _compute_force_flags( + prev_sub_entry, sub_entry, dirty_source_names) + force_this_sub = force_this_sub or self.force # Check for up-to-date outputs. do_bake = True - if not force_this: + if not force_this_sub: try: - in_path_time = page.path_mtime + in_path_time = qualified_page.path_mtime out_path_time = os.path.getmtime(out_path) if out_path_time >= in_path_time: do_bake = False @@ -188,10 +87,10 @@ # If this page didn't bake because it's already up-to-date. # Keep trying for as many subs as we know this page has. if not do_bake: - prev_record_sub_entry.collapseRenderPasses(record_sub_entry) - record_sub_entry.flags = BakeRecordSubPageEntry.FLAG_NONE + sub_entry.render_info = prev_sub_entry.copyRenderInfo() + sub_entry.flags = SubPageBakeInfo.FLAG_NONE - if prev_record_entry.num_subs >= cur_sub + 1: + if prev_entry.num_subs >= cur_sub + 1: cur_sub += 1 has_more_subs = True logger.debug(" %s is up to date, skipping to next " @@ -207,34 +106,25 @@ cache_key = sub_uri self.app.env.rendered_segments_repository.invalidate( cache_key) - record_sub_entry.flags |= \ - BakeRecordSubPageEntry.FLAG_FORMATTING_INVALIDATED + sub_entry.flags |= \ + SubPageBakeInfo.FLAG_FORMATTING_INVALIDATED logger.debug(" p%d -> %s" % (cur_sub, out_path)) - qp = QualifiedPage(page, route, route_metadata) - ctx, rp = self._bakeSingle(qp, cur_sub, out_path, - bake_taxonomy_info) + rp = self._bakeSingle(qualified_page, cur_sub, out_path, + tax_info) except Exception as ex: - if self.app.debug: - logger.exception(ex) - page_rel_path = os.path.relpath(page.path, self.app.root_dir) + page_rel_path = os.path.relpath(qualified_page.path, + self.app.root_dir) raise BakingError("%s: error baking '%s'." % - (page_rel_path, uri)) from ex + (page_rel_path, sub_uri)) from ex # Record what we did. - record_sub_entry.flags |= BakeRecordSubPageEntry.FLAG_BAKED - self.record.dirty_source_names.add(record_entry.source_name) - for p, pinfo in ctx.render_passes.items(): - brpi = BakeRecordPassInfo() - brpi.used_source_names = set(pinfo.used_source_names) - brpi.used_taxonomy_terms = set(pinfo.used_taxonomy_terms) - record_sub_entry.render_passes[p] = brpi - if prev_record_sub_entry: - prev_record_sub_entry.collapseRenderPasses(record_sub_entry) + sub_entry.flags |= SubPageBakeInfo.FLAG_BAKED + sub_entry.render_info = rp.copyRenderInfo() # Copy page assets. if (cur_sub == 1 and self.copy_assets and - ctx.used_assets is not None): + sub_entry.anyPass(lambda p: p.used_assets)): if self.pretty_urls: out_assets_dir = os.path.dirname(out_path) else: @@ -244,47 +134,115 @@ out_assets_dir += out_name_noext logger.debug("Copying page assets to: %s" % out_assets_dir) - if not os.path.isdir(out_assets_dir): - os.makedirs(out_assets_dir, 0o755) - for ap in ctx.used_assets: - dest_ap = os.path.join(out_assets_dir, - os.path.basename(ap)) - logger.debug(" %s -> %s" % (ap, dest_ap)) - shutil.copy(ap, dest_ap) - record_entry.assets.append(ap) + _ensure_dir_exists(out_assets_dir) + + page_dirname = os.path.dirname(qualified_page.path) + page_pathname, _ = os.path.splitext(qualified_page.path) + in_assets_dir = page_pathname + ASSET_DIR_SUFFIX + for fn in os.listdir(in_assets_dir): + full_fn = os.path.join(page_dirname, fn) + if os.path.isfile(full_fn): + dest_ap = os.path.join(out_assets_dir, fn) + logger.debug(" %s -> %s" % (full_fn, dest_ap)) + shutil.copy(full_fn, dest_ap) # Figure out if we have more work. has_more_subs = False - if ctx.used_pagination is not None: - if ctx.used_pagination.has_more: - cur_sub += 1 - has_more_subs = True + if sub_entry.anyPass(lambda p: p.pagination_has_more): + cur_sub += 1 + has_more_subs = True + + return sub_entries - def _bakeSingle(self, qualified_page, num, out_path, taxonomy_info=None): + def _bakeSingle(self, qualified_page, num, out_path, tax_info=None): ctx = PageRenderingContext(qualified_page, page_num=num) - if taxonomy_info: - ctx.setTaxonomyFilter(taxonomy_info[0], taxonomy_info[1]) + if tax_info: + tax = self.app.getTaxonomy(tax_info.taxonomy_name) + ctx.setTaxonomyFilter(tax, tax_info.term) rp = render_page(ctx) out_dir = os.path.dirname(out_path) - if not os.path.isdir(out_dir): - os.makedirs(out_dir, 0o755) + _ensure_dir_exists(out_dir) with codecs.open(out_path, 'w', 'utf8') as fp: fp.write(rp.content) - return ctx, rp + return rp + + +def _compute_force_flags(prev_sub_entry, sub_entry, dirty_source_names): + # Figure out what to do with this page. + force_this_sub = False + invalidate_formatting = False + sub_uri = sub_entry.out_uri + if (prev_sub_entry and + (prev_sub_entry.was_baked_successfully or + prev_sub_entry.was_clean)): + # If the current page is known to use pages from other sources, + # see if any of those got baked, or are going to be baked for + # some reason. If so, we need to bake this one too. + # (this happens for instance with the main page of a blog). + dirty_for_this, invalidated_render_passes = ( + _get_dirty_source_names_and_render_passes( + prev_sub_entry, dirty_source_names)) + if len(invalidated_render_passes) > 0: + logger.debug( + "'%s' is known to use sources %s, which have " + "items that got (re)baked. Will force bake this " + "page. " % (sub_uri, dirty_for_this)) + sub_entry.flags |= \ + SubPageBakeInfo.FLAG_FORCED_BY_SOURCE + force_this_sub = True - def _getDirtySourceNamesAndRenderPasses(self, record_sub_entry): - dirty_src_names = set() - invalidated_render_passes = set() - for p, pinfo in record_sub_entry.render_passes.items(): - for src_name in pinfo.used_source_names: - is_dirty = (src_name in self.record.dirty_source_names) - if is_dirty: - invalidated_render_passes.add(p) - dirty_src_names.add(src_name) - break - return dirty_src_names, invalidated_render_passes + if PASS_FORMATTING in invalidated_render_passes: + logger.debug( + "Will invalidate cached formatting for '%s' " + "since sources were using during that pass." + % sub_uri) + invalidate_formatting = True + elif (prev_sub_entry and + prev_sub_entry.errors): + # Previous bake failed. We'll have to bake it again. + logger.debug( + "Previous record entry indicates baking failed for " + "'%s'. Will bake it again." % sub_uri) + sub_entry.flags |= \ + SubPageBakeInfo.FLAG_FORCED_BY_PREVIOUS_ERRORS + force_this_sub = True + elif not prev_sub_entry: + # No previous record. We'll have to bake it. + logger.debug("No previous record entry found for '%s'. Will " + "force bake it." % sub_uri) + sub_entry.flags |= \ + SubPageBakeInfo.FLAG_FORCED_BY_NO_PREVIOUS + force_this_sub = True + + return force_this_sub, invalidate_formatting + +def _get_dirty_source_names_and_render_passes(sub_entry, dirty_source_names): + dirty_for_this = set() + invalidated_render_passes = set() + assert sub_entry.render_info is not None + for p, pinfo in sub_entry.render_info.items(): + for src_name in pinfo.used_source_names: + is_dirty = (src_name in dirty_source_names) + if is_dirty: + invalidated_render_passes.add(p) + dirty_for_this.add(src_name) + break + return dirty_for_this, invalidated_render_passes + + +def _ensure_dir_exists(path): + try: + os.makedirs(path, mode=0o755, exist_ok=True) + except OSError: + # In a multiprocess environment, several process may very + # occasionally try to create the same directory at the same time. + # Let's ignore any error and if something's really wrong (like file + # acces permissions or whatever), then it will more legitimately fail + # just after this when we try to write files. + pass +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/baking/worker.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,220 @@ +import time +import logging +from piecrust.app import PieCrust +from piecrust.baking.records import BakeRecord, _get_transition_key +from piecrust.baking.single import PageBaker, BakingError +from piecrust.environment import AbortedSourceUseError +from piecrust.rendering import ( + QualifiedPage, PageRenderingContext, render_page_segments) +from piecrust.routing import create_route_metadata +from piecrust.sources.base import PageFactory +from piecrust.workerpool import IWorker + + +logger = logging.getLogger(__name__) + + +class BakeWorkerContext(object): + def __init__(self, root_dir, sub_cache_dir, out_dir, + previous_record_path=None, + force=False, debug=False): + self.root_dir = root_dir + self.sub_cache_dir = sub_cache_dir + self.out_dir = out_dir + self.previous_record_path = previous_record_path + self.force = force + self.debug = debug + self.app = None + self.previous_record = None + self.previous_record_index = None + + +class BakeWorker(IWorker): + def __init__(self, ctx): + self.ctx = ctx + self.work_start_time = time.perf_counter() + + def initialize(self): + # Create the app local to this worker. + app = PieCrust(self.ctx.root_dir, debug=self.ctx.debug) + app._useSubCacheDir(self.ctx.sub_cache_dir) + app.env.fs_cache_only_for_main_page = True + app.env.registerTimer("BakeWorker_%d_Total" % self.wid) + app.env.registerTimer("BakeWorkerInit") + app.env.registerTimer("JobReceive") + self.ctx.app = app + + # Load previous record + if self.ctx.previous_record_path: + self.ctx.previous_record = BakeRecord.load( + self.ctx.previous_record_path) + self.ctx.previous_record_index = {} + for e in self.ctx.previous_record.entries: + key = _get_transition_key(e.path, e.taxonomy_info) + self.ctx.previous_record_index[key] = e + + # Create the job handlers. + job_handlers = { + JOB_LOAD: LoadJobHandler(self.ctx), + JOB_RENDER_FIRST: RenderFirstSubJobHandler(self.ctx), + JOB_BAKE: BakeJobHandler(self.ctx)} + for jt, jh in job_handlers.items(): + app.env.registerTimer(type(jh).__name__) + self.job_handlers = job_handlers + + app.env.stepTimerSince("BakeWorkerInit", self.work_start_time) + + def process(self, job): + handler = self.job_handlers[job['type']] + with self.ctx.app.env.timerScope(type(handler).__name__): + return handler.handleJob(job['job']) + + def getReport(self): + self.ctx.app.env.stepTimerSince("BakeWorker_%d_Total" % self.wid, + self.work_start_time) + return { + 'type': 'timers', + 'data': self.ctx.app.env._timers} + + +JOB_LOAD, JOB_RENDER_FIRST, JOB_BAKE = range(0, 3) + + +class JobHandler(object): + def __init__(self, ctx): + self.ctx = ctx + + @property + def app(self): + return self.ctx.app + + def handleJob(self, job): + raise NotImplementedError() + + +def _get_errors(ex): + errors = [] + while ex is not None: + errors.append(str(ex)) + ex = ex.__cause__ + return errors + + +def save_factory(fac): + return { + 'source_name': fac.source.name, + 'rel_path': fac.rel_path, + 'metadata': fac.metadata} + + +def load_factory(app, info): + source = app.getSource(info['source_name']) + return PageFactory(source, info['rel_path'], info['metadata']) + + +class LoadJobHandler(JobHandler): + def handleJob(self, job): + # Just make sure the page has been cached. + fac = load_factory(self.app, job) + logger.debug("Loading page: %s" % fac.ref_spec) + result = { + 'source_name': fac.source.name, + 'path': fac.path, + 'config': None, + 'errors': None} + try: + page = fac.buildPage() + page._load() + result['config'] = page.config.getAll() + except Exception as ex: + logger.debug("Got loading error. Sending it to master.") + result['errors'] = _get_errors(ex) + if self.ctx.debug: + logger.exception(ex) + return result + + +class RenderFirstSubJobHandler(JobHandler): + def handleJob(self, job): + # Render the segments for the first sub-page of this page. + fac = load_factory(self.app, job) + + # These things should be OK as they're checked upstream by the baker. + route = self.app.getRoute(fac.source.name, fac.metadata, + skip_taxonomies=True) + assert route is not None + + page = fac.buildPage() + route_metadata = create_route_metadata(page) + qp = QualifiedPage(page, route, route_metadata) + ctx = PageRenderingContext(qp) + self.app.env.abort_source_use = True + + result = { + 'path': fac.path, + 'aborted': False, + 'errors': None} + logger.debug("Preparing page: %s" % fac.ref_spec) + try: + render_page_segments(ctx) + except AbortedSourceUseError: + logger.debug("Page %s was aborted." % fac.ref_spec) + result['aborted'] = True + except Exception as ex: + logger.debug("Got rendering error. Sending it to master.") + result['errors'] = _get_errors(ex) + if self.ctx.debug: + logger.exception(ex) + finally: + self.app.env.abort_source_use = False + return result + + +class BakeJobHandler(JobHandler): + def __init__(self, ctx): + super(BakeJobHandler, self).__init__(ctx) + self.page_baker = PageBaker(ctx.app, ctx.out_dir, ctx.force) + + def handleJob(self, job): + # Actually bake the page and all its sub-pages to the output folder. + fac = load_factory(self.app, job['factory_info']) + + route_metadata = job['route_metadata'] + tax_info = job['taxonomy_info'] + if tax_info is not None: + route = self.app.getTaxonomyRoute(tax_info.taxonomy_name, + tax_info.source_name) + else: + route = self.app.getRoute(fac.source.name, route_metadata, + skip_taxonomies=True) + assert route is not None + + page = fac.buildPage() + qp = QualifiedPage(page, route, route_metadata) + + result = { + 'path': fac.path, + 'taxonomy_info': tax_info, + 'sub_entries': None, + 'errors': None} + dirty_source_names = job['dirty_source_names'] + + previous_entry = None + if self.ctx.previous_record_index is not None: + key = _get_transition_key(fac.path, tax_info) + previous_entry = self.ctx.previous_record_index.get(key) + + logger.debug("Baking page: %s" % fac.ref_spec) + try: + sub_entries = self.page_baker.bake( + qp, previous_entry, dirty_source_names, tax_info) + result['sub_entries'] = sub_entries + + except BakingError as ex: + logger.debug("Got baking error. Sending it to master.") + result['errors'] = _get_errors(ex) + if self.ctx.debug: + logger.exception(ex) + + return result +
--- a/piecrust/cache.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/cache.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,9 +1,12 @@ import os import os.path +import json import shutil import codecs +import hashlib import logging -import threading +import collections +import repoze.lru logger = logging.getLogger(__name__) @@ -12,7 +15,6 @@ class ExtensibleCache(object): def __init__(self, base_dir): self.base_dir = base_dir - self.lock = threading.Lock() self.caches = {} @property @@ -22,15 +24,12 @@ def getCache(self, name): c = self.caches.get(name) if c is None: - with self.lock: - c = self.caches.get(name) - if c is None: - c_dir = os.path.join(self.base_dir, name) - if not os.path.isdir(c_dir): - os.makedirs(c_dir, 0o755) + c_dir = os.path.join(self.base_dir, name) + if not os.path.isdir(c_dir): + os.makedirs(c_dir, 0o755) - c = SimpleCache(c_dir) - self.caches[name] = c + c = SimpleCache(c_dir) + self.caches[name] = c return c def getCacheDir(self, name): @@ -48,6 +47,10 @@ logger.debug("Cleaning cache: %s" % cache_dir) shutil.rmtree(cache_dir) + # Re-create the cache-dir because now our Cache instance points + # to a directory that doesn't exist anymore. + os.makedirs(cache_dir, 0o755) + def clearCaches(self, except_names=None): for name in self.getCacheNames(except_names=except_names): self.clearCache(name) @@ -145,3 +148,70 @@ def clearCaches(self, except_names=None): pass + +def _make_fs_cache_key(key): + return hashlib.md5(key.encode('utf8')).hexdigest() + + +class MemCache(object): + """ Simple memory cache. It can be backed by a simple file-system + cache, but items need to be JSON-serializable to do this. + """ + def __init__(self, size=2048): + self.cache = repoze.lru.LRUCache(size) + self.fs_cache = None + self._last_access_hit = None + self._invalidated_fs_items = set() + + @property + def last_access_hit(self): + return self._last_access_hit + + def invalidate(self, key): + logger.debug("Invalidating cache item '%s'." % key) + self.cache.invalidate(key) + if self.fs_cache: + logger.debug("Invalidating FS cache item '%s'." % key) + fs_key = _make_fs_cache_key(key) + self._invalidated_fs_items.add(fs_key) + + def put(self, key, item, save_to_fs=True): + self.cache.put(key, item) + if self.fs_cache and save_to_fs: + fs_key = _make_fs_cache_key(key) + item_raw = json.dumps(item) + self.fs_cache.write(fs_key, item_raw) + + def get(self, key, item_maker, fs_cache_time=None, save_to_fs=True): + self._last_access_hit = True + item = self.cache.get(key) + if item is None: + if (self.fs_cache is not None and + fs_cache_time is not None): + # Try first from the file-system cache. + fs_key = _make_fs_cache_key(key) + if (fs_key not in self._invalidated_fs_items and + self.fs_cache.isValid(fs_key, fs_cache_time)): + logger.debug("'%s' found in file-system cache." % + key) + item_raw = self.fs_cache.read(fs_key) + item = json.loads( + item_raw, + object_pairs_hook=collections.OrderedDict) + self.cache.put(key, item) + return item + + # Look into the mem-cache. + logger.debug("'%s' not found in cache, must build." % key) + item = item_maker() + self.cache.put(key, item) + self._last_access_hit = False + + # Save to the file-system if needed. + if self.fs_cache is not None and save_to_fs: + item_raw = json.dumps(item) + self.fs_cache.write(fs_key, item_raw) + + return item + +
--- a/piecrust/chefutil.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/chefutil.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,9 +1,21 @@ import time +import logging +import contextlib from colorama import Fore +@contextlib.contextmanager +def format_timed_scope(logger, message, *, level=logging.INFO, colored=True, + timer_env=None, timer_category=None): + start_time = time.perf_counter() + yield + logger.log(level, format_timed(start_time, message, colored=colored)) + if timer_env is not None and timer_category is not None: + timer_env.stepTimer(timer_category, time.perf_counter() - start_time) + + def format_timed(start_time, message, indent_level=0, colored=True): - end_time = time.clock() + end_time = time.perf_counter() indent = indent_level * ' ' time_str = '%8.1f ms' % ((end_time - start_time) * 1000.0) if colored:
--- a/piecrust/commands/builtin/baking.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/commands/builtin/baking.py Sat Jul 11 20:33:55 2015 -0700 @@ -4,17 +4,19 @@ import hashlib import fnmatch import datetime +from colorama import Fore from piecrust.baking.baker import Baker from piecrust.baking.records import ( - BakeRecord, BakeRecordPageEntry, BakeRecordSubPageEntry) + BakeRecord, BakeRecordEntry, SubPageBakeInfo) from piecrust.chefutil import format_timed from piecrust.commands.base import ChefCommand -from piecrust.processing.base import ProcessorPipeline +from piecrust.processing.pipeline import ProcessorPipeline from piecrust.processing.records import ( ProcessorPipelineRecord, - FLAG_PREPARED, FLAG_PROCESSED, FLAG_OVERRIDEN, + FLAG_PREPARED, FLAG_PROCESSED, FLAG_BYPASSED_STRUCTURED_PROCESSING) -from piecrust.rendering import PASS_FORMATTING, PASS_RENDERING +from piecrust.rendering import ( + PASS_FORMATTING, PASS_RENDERING) logger = logging.getLogger(__name__) @@ -36,12 +38,24 @@ help="Force re-baking the entire website.", action='store_true') parser.add_argument( + '-w', '--workers', + help="The number of worker processes to spawn.", + type=int, default=-1) + parser.add_argument( + '--batch-size', + help="The number of jobs per batch.", + type=int, default=-1) + parser.add_argument( '--assets-only', help="Only bake the assets (don't bake the web pages).", action='store_true') parser.add_argument( '--html-only', - help="Only bake HTML files (don't run the asset pipeline).", + help="Only bake the pages (don't run the asset pipeline).", + action='store_true') + parser.add_argument( + '--show-timers', + help="Show detailed timing information.", action='store_true') def run(self, ctx): @@ -49,7 +63,8 @@ os.path.join(ctx.app.root_dir, '_counter')) success = True - start_time = time.clock() + ctx.timers = {} + start_time = time.perf_counter() try: # Bake the site sources. if not ctx.args.assets_only: @@ -59,6 +74,12 @@ if not ctx.args.html_only: success = success & self._bakeAssets(ctx, out_dir) + # Show merged timers. + if ctx.args.show_timers: + logger.info("-------------------") + logger.info("Timing information:") + _show_timers(ctx.timers) + # All done. logger.info('-------------------------') logger.info(format_timed(start_time, 'done baking')) @@ -71,10 +92,15 @@ return 1 def _bakeSources(self, ctx, out_dir): + if ctx.args.workers > 0: + ctx.app.config.set('baker/workers', ctx.args.workers) + if ctx.args.batch_size > 0: + ctx.app.config.set('baker/batch_size', ctx.args.batch_size) baker = Baker( ctx.app, out_dir, force=ctx.args.force) record = baker.bake() + _merge_timers(record.timers, ctx.timers) return record.success def _bakeAssets(self, ctx, out_dir): @@ -82,9 +108,41 @@ ctx.app, out_dir, force=ctx.args.force) record = proc.run() + _merge_timers(record.timers, ctx.timers) return record.success +def _merge_timers(source, target): + if source is None: + return + + for name, val in source.items(): + if isinstance(val, float): + if name not in target: + target[name] = 0 + target[name] += val + elif isinstance(val, dict): + if name not in target: + target[name] = {} + _merge_timers(val, target[name]) + + +def _show_timers(timers, indent=''): + sub_timer_names = [] + for name in sorted(timers.keys()): + if isinstance(timers[name], float): + val_str = '%8.1f s' % timers[name] + logger.info( + "%s[%s%s%s] %s" % + (indent, Fore.GREEN, val_str, Fore.RESET, name)) + else: + sub_timer_names.append(name) + + for name in sub_timer_names: + logger.info('%s:' % name) + _show_timers(timers[name], indent + ' ') + + class ShowRecordCommand(ChefCommand): def __init__(self): super(ShowRecordCommand, self).__init__() @@ -111,6 +169,15 @@ type=int, default=0, help="Show the last Nth bake record.") + parser.add_argument( + '--html-only', + action='store_true', + help="Only show records for pages (not from the asset " + "pipeline).") + parser.add_argument( + '--assets-only', + action='store_true', + help="Only show records for assets (not from pages).") def run(self, ctx): out_dir = ctx.args.output or os.path.join(ctx.app.root_dir, '_counter') @@ -126,12 +193,18 @@ if ctx.args.out: out_pattern = '*%s*' % ctx.args.out.strip('*') + if not ctx.args.assets_only: + self._showBakeRecord(ctx, record_name, pattern, out_pattern) + if not ctx.args.html_only: + self._showProcessingRecord(ctx, record_name, pattern, out_pattern) + + def _showBakeRecord(self, ctx, record_name, pattern, out_pattern): + # Show the bake record. record_cache = ctx.app.cache.getCache('baker') if not record_cache.has(record_name): raise Exception("No record has been created for this output path. " "Did you bake there yet?") - # Show the bake record. record = BakeRecord.load(record_cache.getCachePath(record_name)) logging.info("Bake record for: %s" % record.out_dir) logging.info("From: %s" % record_name) @@ -143,68 +216,90 @@ logging.error("Status: failed") logging.info("Entries:") for entry in record.entries: - if pattern and not fnmatch.fnmatch(entry.rel_path, pattern): + if pattern and not fnmatch.fnmatch(entry.path, pattern): continue if out_pattern and not ( any([o for o in entry.out_paths if fnmatch.fnmatch(o, out_pattern)])): continue - flags = [] - if entry.flags & BakeRecordPageEntry.FLAG_OVERRIDEN: - flags.append('overriden') - - passes = {PASS_RENDERING: 'render', PASS_FORMATTING: 'format'} + flags = _get_flag_descriptions( + entry.flags, + { + BakeRecordEntry.FLAG_NEW: 'new', + BakeRecordEntry.FLAG_SOURCE_MODIFIED: 'modified', + BakeRecordEntry.FLAG_OVERRIDEN: 'overriden'}) logging.info(" - ") - logging.info(" path: %s" % entry.rel_path) - logging.info(" spec: %s:%s" % (entry.source_name, - entry.rel_path)) + + rel_path = os.path.relpath(entry.path, ctx.app.root_dir) + logging.info(" path: %s" % rel_path) + logging.info(" source: %s" % entry.source_name) if entry.taxonomy_info: - tn, t, sn = entry.taxonomy_info - logging.info(" taxonomy: %s (%s:%s)" % - (t, sn, tn)) + ti = entry.taxonomy_info + logging.info(" taxonomy: %s = %s (in %s)" % + (ti.taxonomy_name, ti.term, ti.source_name)) else: logging.info(" taxonomy: <none>") - logging.info(" flags: %s" % ', '.join(flags)) + logging.info(" flags: %s" % _join(flags)) logging.info(" config: %s" % entry.config) + if entry.errors: + logging.error(" errors: %s" % entry.errors) + logging.info(" %d sub-pages:" % len(entry.subs)) for sub in entry.subs: + sub_flags = _get_flag_descriptions( + sub.flags, + { + SubPageBakeInfo.FLAG_BAKED: 'baked', + SubPageBakeInfo.FLAG_FORCED_BY_SOURCE: + 'forced by source', + SubPageBakeInfo.FLAG_FORCED_BY_NO_PREVIOUS: + 'forced by missing previous record entry', + SubPageBakeInfo.FLAG_FORCED_BY_PREVIOUS_ERRORS: + 'forced by previous errors', + SubPageBakeInfo.FLAG_FORMATTING_INVALIDATED: + 'formatting invalidated'}) + logging.info(" - ") logging.info(" URL: %s" % sub.out_uri) - logging.info(" path: %s" % os.path.relpath(sub.out_path, - out_dir)) - logging.info(" baked?: %s" % sub.was_baked) + logging.info(" path: %s" % os.path.relpath( + sub.out_path, record.out_dir)) + logging.info(" flags: %s" % _join(sub_flags)) - sub_flags = [] - if sub.flags & BakeRecordSubPageEntry.FLAG_FORCED_BY_SOURCE: - sub_flags.append('forced by source') - if sub.flags & BakeRecordSubPageEntry.FLAG_FORCED_BY_NO_PREVIOUS: - sub_flags.append('forced by missing previous record entry') - if sub.flags & BakeRecordSubPageEntry.FLAG_FORCED_BY_PREVIOUS_ERRORS: - sub_flags.append('forced by previous errors') - logging.info(" flags: %s" % ', '.join(sub_flags)) - - for p, pi in sub.render_passes.items(): - logging.info(" %s pass:" % passes[p]) - logging.info(" used srcs: %s" % - ', '.join(pi.used_source_names)) - logging.info(" used terms: %s" % - ', '.join( - ['%s (%s:%s)' % (t, sn, tn) - for sn, tn, t in pi.used_taxonomy_terms])) + if sub.render_info: + pass_names = { + PASS_FORMATTING: 'formatting pass', + PASS_RENDERING: 'rendering pass'} + for p, ri in sub.render_info.items(): + logging.info(" - %s" % pass_names[p]) + logging.info(" used sources: %s" % + _join(ri.used_source_names)) + pgn_info = 'no' + if ri.used_pagination: + pgn_info = 'yes' + if ri.pagination_has_more: + pgn_info += ', has more' + logging.info(" used pagination: %s", pgn_info) + logging.info(" used assets: %s", + 'yes' if ri.used_assets else 'no') + logging.info(" used terms: %s" % + _join( + ['%s=%s (%s)' % (tn, t, sn) + for sn, tn, t in + ri.used_taxonomy_terms])) + else: + logging.info(" no render info") if sub.errors: logging.error(" errors: %s" % sub.errors) - logging.info(" assets: %s" % ', '.join(entry.assets)) - if entry.errors: - logging.error(" errors: %s" % entry.errors) - + def _showProcessingRecord(self, ctx, record_name, pattern, out_pattern): record_cache = ctx.app.cache.getCache('proc') if not record_cache.has(record_name): - return + raise Exception("No record has been created for this output path. " + "Did you bake there yet?") # Show the pipeline record. record = ProcessorPipelineRecord.load( @@ -219,37 +314,51 @@ logging.error("Status: failed") logging.info("Entries:") for entry in record.entries: - if pattern and not fnmatch.fnmatch(entry.rel_input, pattern): + rel_path = os.path.relpath(entry.path, ctx.app.root_dir) + if pattern and not fnmatch.fnmatch(rel_path, pattern): continue if out_pattern and not ( any([o for o in entry.rel_outputs if fnmatch.fnmatch(o, out_pattern)])): continue - flags = [] - if entry.flags & FLAG_PREPARED: - flags.append('prepared') - if entry.flags & FLAG_PROCESSED: - flags.append('processed') - if entry.flags & FLAG_OVERRIDEN: - flags.append('overriden') - if entry.flags & FLAG_BYPASSED_STRUCTURED_PROCESSING: - flags.append('external') + flags = _get_flag_descriptions( + entry.flags, + { + FLAG_PREPARED: 'prepared', + FLAG_PROCESSED: 'processed', + FLAG_BYPASSED_STRUCTURED_PROCESSING: 'external'}) + logger.info(" - ") - logger.info(" path: %s" % entry.rel_input) + logger.info(" path: %s" % rel_path) logger.info(" out paths: %s" % entry.rel_outputs) - logger.info(" flags: %s" % flags) - logger.info(" proc tree: %s" % format_proc_tree( + logger.info(" flags: %s" % _join(flags)) + logger.info(" proc tree: %s" % _format_proc_tree( entry.proc_tree, 14*' ')) + if entry.errors: logger.error(" errors: %s" % entry.errors) -def format_proc_tree(tree, margin='', level=0): +def _join(items, sep=', ', text_if_none='none'): + if items: + return sep.join(items) + return text_if_none + + +def _get_flag_descriptions(flags, descriptions): + res = [] + for k, v in descriptions.items(): + if flags & k: + res.append(v) + return res + + +def _format_proc_tree(tree, margin='', level=0): name, children = tree res = '%s%s+ %s\n' % (margin if level > 0 else '', level * ' ', name) if children: for c in children: - res += format_proc_tree(c, margin, level + 1) + res += _format_proc_tree(c, margin, level + 1) return res
--- a/piecrust/configuration.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/configuration.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,65 +1,45 @@ import re -import copy import logging -import collections +import collections.abc import yaml from yaml.constructor import ConstructorError +try: + from yaml import CSafeLoader as SafeLoader +except ImportError: + from yaml import SafeLoader logger = logging.getLogger(__name__) -default_allowed_types = (dict, list, tuple, int, bool, str) +default_allowed_types = (dict, list, tuple, float, int, bool, str) class ConfigurationError(Exception): pass -class Configuration(object): +class Configuration(collections.abc.MutableMapping): def __init__(self, values=None, validate=True): if values is not None: - self.setAll(values, validate) + self.setAll(values, validate=validate) else: self._values = None - def __contains__(self, key): - return self.has(key) - def __getitem__(self, key): - value = self.get(key) - if value is None: - raise KeyError() - return value + self._ensureLoaded() + bits = key.split('/') + cur = self._values + for b in bits: + try: + cur = cur[b] + except KeyError: + raise KeyError("No such item: %s" % key) + return cur def __setitem__(self, key, value): - return self.set(key, value) - - def setAll(self, values, validate=True): - if validate: - self._validateAll(values) - self._values = values - - def getDeepcopy(self, validate_types=False): - if validate_types: - self.validateTypes() - return copy.deepcopy(self.get()) - - def get(self, key_path=None, default_value=None): self._ensureLoaded() - if key_path is None: - return self._values - bits = key_path.split('/') - cur = self._values - for b in bits: - cur = cur.get(b) - if cur is None: - return default_value - return cur - - def set(self, key_path, value): - self._ensureLoaded() - value = self._validateValue(key_path, value) - bits = key_path.split('/') + value = self._validateValue(key, value) + bits = key.split('/') bitslen = len(bits) cur = self._values for i, b in enumerate(bits): @@ -70,15 +50,31 @@ cur[b] = {} cur = cur[b] - def has(self, key_path): + def __delitem__(self, key): + raise NotImplementedError() + + def __iter__(self): + self._ensureLoaded() + return iter(self._values) + + def __len__(self): self._ensureLoaded() - bits = key_path.split('/') - cur = self._values - for b in bits: - cur = cur.get(b) - if cur is None: - return False - return True + return len(self._values) + + def has(self, key): + return key in self + + def set(self, key, value): + self[key] = value + + def setAll(self, values, validate=False): + if validate: + self._validateAll(values) + self._values = values + + def getAll(self): + self._ensureLoaded() + return self._values def merge(self, other): self._ensureLoaded() @@ -174,7 +170,7 @@ return config, offset -class ConfigurationLoader(yaml.SafeLoader): +class ConfigurationLoader(SafeLoader): """ A YAML loader that loads mappings into ordered dictionaries. """ def __init__(self, *args, **kwargs):
--- a/piecrust/data/assetor.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/data/assetor.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,6 +1,7 @@ import os import os.path import logging +from piecrust import ASSET_DIR_SUFFIX from piecrust.uriutil import multi_replace @@ -31,8 +32,6 @@ class Assetor(object): - ASSET_DIR_SUFFIX = '-assets' - debug_render_doc = """Helps render URLs to files in the current page's asset folder.""" debug_render = [] @@ -58,6 +57,10 @@ self._cacheAssets() return map(lambda i: i[0], self._cache.values()) + def _getFilenames(self): + assert self._cache is not None + return map(lambda i: i[1], self._cache.values()) + def _debugRenderAssetNames(self): self._cacheAssets() return list(self._cache.keys()) @@ -68,7 +71,7 @@ self._cache = {} name, ext = os.path.splitext(self._page.path) - assets_dir = name + Assetor.ASSET_DIR_SUFFIX + assets_dir = name + ASSET_DIR_SUFFIX if not os.path.isdir(assets_dir): return @@ -88,6 +91,5 @@ cpi = self._page.app.env.exec_info_stack.current_page_info if cpi is not None: - used_assets = list(map(lambda i: i[1], self._cache.values())) - cpi.render_ctx.used_assets = used_assets + cpi.render_ctx.used_assets = True
--- a/piecrust/data/base.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/data/base.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,217 +1,62 @@ -import copy -import time -import logging -from piecrust.data.assetor import Assetor -from piecrust.uriutil import split_uri - - -logger = logging.getLogger(__name__) - - -class LazyPageConfigLoaderHasNoValue(Exception): - """ An exception that can be returned when a loader for `LazyPageConfig` - can't return any value. - """ - pass +import collections.abc -class LazyPageConfigData(object): - """ An object that represents the configuration header of a page, - but also allows for additional data. It's meant to be exposed - to the templating system. +class MergedMapping(collections.abc.Mapping): + """ Provides a dictionary-like object that's really the aggregation of + multiple dictionary-like objects. """ - debug_render = [] - debug_render_dynamic = ['_debugRenderKeys'] - - def __init__(self, page): - self._page = page - self._values = None - self._loaders = None - - @property - def page(self): - return self._page - - def get(self, name): - try: - return self._getValue(name) - except LazyPageConfigLoaderHasNoValue: - return None + def __init__(self, dicts, path=''): + self._dicts = dicts + self._path = path def __getattr__(self, name): try: - return self._getValue(name) - except LazyPageConfigLoaderHasNoValue: - raise AttributeError + return self[name] + except KeyError: + raise AttributeError("No such attribute: %s" % self._subp(name)) def __getitem__(self, name): - try: - return self._getValue(name) - except LazyPageConfigLoaderHasNoValue: - raise KeyError - - def _getValue(self, name): - self._load() - - if name in self._values: - return self._values[name] - - if self._loaders: - loader = self._loaders.get(name) - if loader is not None: - try: - self._values[name] = loader(self, name) - except LazyPageConfigLoaderHasNoValue: - raise - except Exception as ex: - raise Exception( - "Error while loading attribute '%s' for: %s" % - (name, self._page.rel_path)) from ex - - # We need to double-check `_loaders` here because - # the loader could have removed all loaders, which - # would set this back to `None`. - if self._loaders is not None: - del self._loaders[name] - if len(self._loaders) == 0: - self._loaders = None + values = [] + for d in self._dicts: + try: + val = d[name] + except KeyError: + continue + values.append(val) - else: - loader = self._loaders.get('*') - if loader is not None: - try: - self._values[name] = loader(self, name) - except LazyPageConfigLoaderHasNoValue: - raise - except Exception as ex: - raise Exception( - "Error while loading attribute '%s' for: %s" % - (name, self._page.rel_path)) from ex - # We always keep the wildcard loader in the loaders list. - - if name not in self._values: - raise LazyPageConfigLoaderHasNoValue() - return self._values[name] - - def _setValue(self, name, value): - if self._values is None: - raise Exception("Can't call _setValue before this data has been " - "loaded") - self._values[name] = value + if len(values) == 0: + raise KeyError("No such item: %s" % self._subp(name)) + if len(values) == 1: + return values[0] - def mapLoader(self, attr_name, loader): - if loader is None: - if self._loaders is None or attr_name not in self._loaders: - return - del self._loaders[attr_name] - if len(self._loaders) == 0: - self._loaders = None - return - - if self._loaders is None: - self._loaders = {} - if attr_name in self._loaders: - raise Exception( - "A loader has already been mapped for: %s" % attr_name) - self._loaders[attr_name] = loader - - def _load(self): - if self._values is not None: - return - self._values = self._page.config.getDeepcopy(self._page.app.debug) - try: - self._loadCustom() - except Exception as ex: - raise Exception( - "Error while loading data for: %s" % - self._page.rel_path) from ex - - def _loadCustom(self): - pass + for val in values: + if not isinstance(val, (dict, collections.abc.Mapping)): + raise Exception( + "Template data for '%s' contains an incompatible mix " + "of data: %s" % ( + self._subp(name), + ', '.join([str(type(v)) for v in values]))) - def _debugRenderKeys(self): - self._load() - keys = set(self._values.keys()) - if self._loaders: - keys |= set(self._loaders.keys()) - return list(keys) - - -class PaginationData(LazyPageConfigData): - def __init__(self, page): - super(PaginationData, self).__init__(page) - self._route = None - self._route_metadata = None + return MergedMapping(values, self._subp(name)) - def _get_uri(self): - page = self._page - if self._route is None: - # TODO: this is not quite correct, as we're missing parts of the - # route metadata if the current page is a taxonomy page. - self._route = page.app.getRoute(page.source.name, - page.source_metadata) - self._route_metadata = copy.deepcopy(page.source_metadata) - if self._route is None: - raise Exception("Can't get route for page: %s" % page.path) - return self._route.getUri(self._route_metadata, provider=page) - - def _loadCustom(self): - page_url = self._get_uri() - _, slug = split_uri(self.page.app, page_url) - self._setValue('url', page_url) - self._setValue('slug', slug) - self._setValue( - 'timestamp', - time.mktime(self.page.datetime.timetuple())) - date_format = self.page.app.config.get('site/date_format') - if date_format: - self._setValue('date', self.page.datetime.strftime(date_format)) - - assetor = Assetor(self.page, page_url) - self._setValue('assets', assetor) + def __iter__(self): + keys = set() + for d in self._dicts: + keys |= set(d.keys()) + return iter(keys) - segment_names = self.page.config.get('segments') - for name in segment_names: - self.mapLoader(name, self._load_rendered_segment) - - def _load_rendered_segment(self, data, name): - do_render = True - eis = self._page.app.env.exec_info_stack - if eis is not None and eis.hasPage(self._page): - # This is the pagination data for the page that is currently - # being rendered! Inception! But this is possible... so just - # prevent infinite recursion. - do_render = False - - assert self is data + def __len__(self): + keys = set() + for d in self._dicts: + keys |= set(d.keys()) + return len(keys) - if do_render: - uri = self._get_uri() - try: - from piecrust.rendering import ( - QualifiedPage, PageRenderingContext, - render_page_segments) - qp = QualifiedPage(self._page, self._route, - self._route_metadata) - ctx = PageRenderingContext(qp) - segs = render_page_segments(ctx) - except Exception as e: - raise Exception( - "Error rendering segments for '%s'" % uri) from e - else: - segs = {} - for name in self.page.config.get('segments'): - segs[name] = "<unavailable: current page>" + def _subp(self, name): + return '%s/%s' % (self._path, name) - for k, v in segs.items(): - self.mapLoader(k, None) - self._setValue(k, v) + def _prependMapping(self, d): + self._dicts.insert(0, d) - if 'content.abstract' in segs: - self._setValue('content', segs['content.abstract']) - self._setValue('has_more', True) - if name == 'content': - return segs['content.abstract'] + def _appendMapping(self, d): + self._dicts.append(d) - return segs[name] -
--- a/piecrust/data/builder.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/data/builder.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,14 +1,12 @@ -import re -import time -import copy import logging from werkzeug.utils import cached_property -from piecrust import APP_VERSION -from piecrust.configuration import merge_dicts from piecrust.data.assetor import Assetor -from piecrust.data.debug import build_debug_info +from piecrust.data.base import MergedMapping from piecrust.data.linker import PageLinkerData +from piecrust.data.pagedata import PageData from piecrust.data.paginator import Paginator +from piecrust.data.piecrustdata import PieCrustData +from piecrust.data.providersdata import DataProvidersData from piecrust.uriutil import split_sub_uri @@ -35,9 +33,10 @@ app = ctx.app page = ctx.page first_uri, _ = split_sub_uri(app, ctx.uri) + pgn_source = ctx.pagination_source or get_default_pagination_source(page) pc_data = PieCrustData() - pgn_source = ctx.pagination_source or get_default_pagination_source(page) + config_data = PageData(page, ctx) paginator = Paginator(page, pgn_source, page_num=ctx.page_num, pgn_filter=ctx.pagination_filter) @@ -45,24 +44,17 @@ linker = PageLinkerData(page.source, page.rel_path) data = { 'piecrust': pc_data, - 'page': {}, + 'page': config_data, 'assets': assetor, 'pagination': paginator, 'family': linker } - page_data = data['page'] - page_data.update(copy.deepcopy(page.source_metadata)) - page_data.update(page.config.getDeepcopy(app.debug)) - page_data['url'] = ctx.uri - page_data['timestamp'] = time.mktime(page.datetime.timetuple()) - date_format = app.config.get('site/date_format') - if date_format: - page_data['date'] = page.datetime.strftime(date_format) #TODO: handle slugified taxonomy terms. - site_data = build_site_data(page) - merge_dicts(data, site_data) + site_data = app.config.getAll() + providers_data = DataProvidersData(page) + data = MergedMapping([data, providers_data, site_data]) # Do this at the end because we want all the data to be ready to be # displayed in the debugger window. @@ -78,53 +70,7 @@ if name in page_data: logger.warning("Content segment '%s' will hide existing data." % name) - page_data[name] = txt - - -class PieCrustData(object): - debug_render = ['version', 'url', 'branding', 'debug_info'] - debug_render_invoke = ['version', 'url', 'branding', 'debug_info'] - debug_render_redirect = {'debug_info': '_debugRenderDebugInfo'} - - def __init__(self): - self.version = APP_VERSION - self.url = 'http://bolt80.com/piecrust/' - self.branding = 'Baked with <em><a href="%s">PieCrust</a> %s</em>.' % ( - 'http://bolt80.com/piecrust/', APP_VERSION) - self._page = None - self._data = None - - @property - def debug_info(self): - if self._page is not None and self._data is not None: - return build_debug_info(self._page, self._data) - return '' - - def _enableDebugInfo(self, page, data): - self._page = page - self._data = data - - def _debugRenderDebugInfo(self): - return "The very thing you're looking at!" - - -re_endpoint_sep = re.compile(r'[\/\.]') - - -def build_site_data(page): - app = page.app - data = app.config.getDeepcopy(app.debug) - for source in app.sources: - endpoint_bits = re_endpoint_sep.split(source.data_endpoint) - endpoint = data - for e in endpoint_bits[:-1]: - if e not in endpoint: - endpoint[e] = {} - endpoint = endpoint[e] - user_data = endpoint.get(endpoint_bits[-1]) - provider = source.buildDataProvider(page, user_data) - endpoint[endpoint_bits[-1]] = provider - return data + page_data._prependMapping(contents) def get_default_pagination_source(page):
--- a/piecrust/data/debug.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/data/debug.py Sat Jul 11 20:33:55 2015 -0700 @@ -3,6 +3,7 @@ import html import logging import collections +import collections.abc from piecrust import APP_VERSION, PIECRUST_URL from piecrust.page import FLAG_RAW_CACHE_VALID @@ -162,7 +163,7 @@ self._write('<null>') return - if isinstance(data, dict): + if isinstance(data, (dict, collections.abc.Mapping)): self._renderCollapsableValueStart(path) with IndentScope(self): self._renderDict(data, path) @@ -208,7 +209,8 @@ self._renderDoc(data, path) self._renderAttributes(data, path) rendered_count = self._renderIterable(data, path, lambda d: enumerate(d)) - if rendered_count == 0: + if (rendered_count == 0 and + not hasattr(data.__class__, 'debug_render_not_empty')): self._writeLine('<p style="%s %s">(empty array)</p>' % (CSS_P, CSS_DOC)) self._writeLine('</div>') @@ -218,7 +220,8 @@ self._renderAttributes(data, path) rendered_count = self._renderIterable(data, path, lambda d: sorted(iter(d.items()), key=lambda i: i[0])) - if rendered_count == 0: + if (rendered_count == 0 and + not hasattr(data.__class__, 'debug_render_not_empty')): self._writeLine('<p style="%s %s">(empty dictionary)</p>' % (CSS_P, CSS_DOC)) self._writeLine('</div>') @@ -240,10 +243,12 @@ data.__class__.debug_render_items): rendered_count = self._renderIterable(data, path, lambda d: enumerate(d)) - if rendered_count == 0: + if (rendered_count == 0 and + not hasattr(data.__class__, 'debug_render_not_empty')): self._writeLine('<p style="%s %s">(empty)</p>' % (CSS_P, CSS_DOC)) - elif rendered_attrs == 0: + elif (rendered_attrs == 0 and + not hasattr(data.__class__, 'debug_render_not_empty')): self._writeLine('<p style="%s %s">(empty)</p>' % (CSS_P, CSS_DOC)) self._writeLine('</div>') @@ -265,6 +270,13 @@ self._writeLine('<span style="%s">– %s</span>' % (CSS_DOC, data.__class__.debug_render_doc)) + if hasattr(data.__class__, 'debug_render_doc_dynamic'): + drdd = data.__class__.debug_render_doc_dynamic + for ng in drdd: + doc = getattr(data, ng) + self._writeLine('<span style="%s">– %s</span>' % + (CSS_DOC, doc())) + doc = self.external_docs.get(path) if doc is not None: self._writeLine('<span style="%s">– %s</span>' %
--- a/piecrust/data/iterators.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/data/iterators.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,6 +1,8 @@ import logging from piecrust.data.filters import PaginationFilter +from piecrust.environment import AbortedSourceUseError from piecrust.events import Event +from piecrust.sources.base import PageSource from piecrust.sources.interfaces import IPaginationSource @@ -108,6 +110,10 @@ class PageIterator(object): + debug_render = [] + debug_render_doc_dynamic = ['_debugRenderDoc'] + debug_render_not_empty = True + def __init__(self, source, current_page=None, pagination_filter=None, offset=0, limit=-1, locked=False): self._source = source @@ -285,6 +291,13 @@ if self._pagesData is not None: return + if (self._current_page is not None and + self._current_page.app.env.abort_source_use and + isinstance(self._source, PageSource)): + logger.debug("Aborting iteration from %s." % + self._current_page.ref_spec) + raise AbortedSourceUseError() + self._ensureSorter() it_chain = self._pages @@ -303,3 +316,6 @@ pn_it = self._source.getTailIterator(iter(pn)) self._prev_page, self._next_page = (list(pn_it)) + def _debugRenderDoc(self): + return "Contains %d items" % len(self) +
--- a/piecrust/data/linker.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/data/linker.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,7 +1,8 @@ import logging import collections -from piecrust.data.base import PaginationData, LazyPageConfigLoaderHasNoValue from piecrust.data.iterators import PageIterator +from piecrust.data.pagedata import LazyPageConfigLoaderHasNoValue +from piecrust.data.paginationdata import PaginationData from piecrust.sources.interfaces import IPaginationSource, IListableSource @@ -12,15 +13,22 @@ """ Entry template data to get access to related pages from a given root page. """ + debug_render = ['parent', 'ancestors', 'siblings', 'children', 'root', + 'forpath'] + debug_render_invoke = ['parent', 'siblings', 'children'] + def __init__(self, source, page_path): self._source = source self._root_page_path = page_path self._linker = None + self._is_loaded = False @property def parent(self): self._load() - return self._linker.parent + if self._linker is not None: + return self._linker.parent + return None @property def ancestors(self): @@ -32,11 +40,15 @@ @property def siblings(self): self._load() + if self._linker is None: + return [] return self._linker @property def children(self): self._load() + if self._linker is None: + return [] self._linker._load() if self._linker._self_item is None: return [] @@ -48,14 +60,24 @@ @property def root(self): self._load() + if self._linker is None: + return None return self._linker.root def forpath(self, rel_path): self._load() + if self._linker is None: + return None return self._linker.forpath(rel_path) def _load(self): - if self._linker is not None: + if self._is_loaded: + return + + self._is_loaded = True + + is_listable = isinstance(self._source, IListableSource) + if not is_listable: return dir_path = self._source.getDirpath(self._root_page_path) @@ -69,7 +91,10 @@ `Paginator` and other page iterators, but with a few additional data like hierarchical data. """ - debug_render = ['is_dir', 'is_self'] + PaginationData.debug_render + debug_render = (['is_dir', 'is_self', 'parent', 'children'] + + PaginationData.debug_render) + debug_render_invoke = (['is_dir', 'is_self', 'parent', 'children'] + + PaginationData.debug_render_invoke) def __init__(self, page): super(LinkedPageData, self).__init__(page) @@ -79,7 +104,7 @@ self.is_page = True self._child_linker = page._linker_info.child_linker - self.mapLoader('*', self._linkerChildLoader) + self._mapLoader('*', self._linkerChildLoader) @property def parent(self): @@ -269,36 +294,35 @@ items = list(self._source.listPath(self._dir_path)) self._items = collections.OrderedDict() - with self._source.app.env.page_repository.startBatchGet(): - for is_dir, name, data in items: - # If `is_dir` is true, `data` will be the directory's source - # path. If not, it will be a page factory. - if is_dir: - item = Linker(self._source, data, - root_page_path=self._root_page_path) - else: - page = data.buildPage() - is_self = (page.rel_path == self._root_page_path) - item = _LinkedPage(page) - item._linker_info.name = name - item._linker_info.is_self = is_self - if is_self: - self._self_item = item + for is_dir, name, data in items: + # If `is_dir` is true, `data` will be the directory's source + # path. If not, it will be a page factory. + if is_dir: + item = Linker(self._source, data, + root_page_path=self._root_page_path) + else: + page = data.buildPage() + is_self = (page.rel_path == self._root_page_path) + item = _LinkedPage(page) + item._linker_info.name = name + item._linker_info.is_self = is_self + if is_self: + self._self_item = item - existing = self._items.get(name) - if existing is None: - self._items[name] = item - elif is_dir: - # The current item is a directory. The existing item - # should be a page. - existing._linker_info.child_linker = item - existing._linker_info.is_dir = True - else: - # The current item is a page. The existing item should - # be a directory. - item._linker_info.child_linker = existing - item._linker_info.is_dir = True - self._items[name] = item + existing = self._items.get(name) + if existing is None: + self._items[name] = item + elif is_dir: + # The current item is a directory. The existing item + # should be a page. + existing._linker_info.child_linker = item + existing._linker_info.is_dir = True + else: + # The current item is a page. The existing item should + # be a directory. + item._linker_info.child_linker = existing + item._linker_info.is_dir = True + self._items[name] = item def filter_page_items(item):
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/data/pagedata.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,158 @@ +import time +import collections.abc + + +class LazyPageConfigLoaderHasNoValue(Exception): + """ An exception that can be returned when a loader for `LazyPageConfig` + can't return any value. + """ + pass + + +class LazyPageConfigData(collections.abc.Mapping): + """ An object that represents the configuration header of a page, + but also allows for additional data. It's meant to be exposed + to the templating system. + """ + debug_render = [] + debug_render_invoke = [] + debug_render_dynamic = ['_debugRenderKeys'] + debug_render_invoke_dynamic = ['_debugRenderKeys'] + + def __init__(self, page): + self._page = page + self._values = {} + self._loaders = {} + self._is_loaded = False + + def __getattr__(self, name): + try: + return self._getValue(name) + except LazyPageConfigLoaderHasNoValue as ex: + raise AttributeError("No such attribute: %s" % name) from ex + + def __getitem__(self, name): + try: + return self._getValue(name) + except LazyPageConfigLoaderHasNoValue as ex: + raise KeyError("No such key: %s" % name) from ex + + def __iter__(self): + keys = list(self._page.config.keys()) + keys += list(self._values.keys()) + keys += list(self._loaders.keys()) + return iter(keys) + + def __len__(self): + return len(self._page.config) + len(self._values) + len(self._loaders) + + def _getValue(self, name): + # First try the page configuration itself. + try: + return self._page.config[name] + except KeyError: + pass + + # Then try loaded values. + self._ensureLoaded() + try: + return self._values[name] + except KeyError: + pass + + # Try a loader for a new value. + loader = self._loaders.get(name) + if loader is not None: + try: + self._values[name] = loader(self, name) + except LazyPageConfigLoaderHasNoValue: + raise + except Exception as ex: + raise Exception( + "Error while loading attribute '%s' for: %s" % + (name, self._page.rel_path)) from ex + + # Forget this loader now that it served its purpose. + try: + del self._loaders[name] + except KeyError: + pass + return self._values[name] + + # Try the wildcard loader if it exists. + loader = self._loaders.get('*') + if loader is not None: + try: + self._values[name] = loader(self, name) + except LazyPageConfigLoaderHasNoValue: + raise + except Exception as ex: + raise Exception( + "Error while loading attribute '%s' for: %s" % + (name, self._page.rel_path)) from ex + # We always keep the wildcard loader in the loaders list. + return self._values[name] + + raise LazyPageConfigLoaderHasNoValue() + + def _setValue(self, name, value): + self._values[name] = value + + def _unmapLoader(self, attr_name): + try: + del self._loaders[attr_name] + except KeyError: + pass + + def _mapLoader(self, attr_name, loader, override_existing=False): + assert loader is not None + + if not override_existing and attr_name in self._loaders: + raise Exception( + "A loader has already been mapped for: %s" % attr_name) + self._loaders[attr_name] = loader + + def _mapValue(self, attr_name, value, override_existing=False): + loader = lambda _, __: value + self._mapLoader(attr_name, loader, override_existing=override_existing) + + def _ensureLoaded(self): + if self._is_loaded: + return + + self._is_loaded = True + try: + self._load() + except Exception as ex: + raise Exception( + "Error while loading data for: %s" % + self._page.rel_path) from ex + + def _load(self): + pass + + def _debugRenderKeys(self): + self._ensureLoaded() + keys = set(self._values.keys()) + if self._loaders: + keys |= set(self._loaders.keys()) + return list(keys) + + +class PageData(LazyPageConfigData): + """ Template data for a page. + """ + def __init__(self, page, ctx): + super(PageData, self).__init__(page) + self._ctx = ctx + + def _load(self): + page = self._page + for k, v in page.source_metadata.items(): + self._setValue(k, v) + self._setValue('url', self._ctx.uri) + self._setValue('timestamp', time.mktime(page.datetime.timetuple())) + date_format = page.app.config.get('site/date_format') + if date_format: + self._setValue('date', page.datetime.strftime(date_format)) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/data/paginationdata.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,88 @@ +import time +from piecrust.data.assetor import Assetor +from piecrust.data.pagedata import LazyPageConfigData +from piecrust.routing import create_route_metadata +from piecrust.uriutil import split_uri + + +class PaginationData(LazyPageConfigData): + def __init__(self, page): + super(PaginationData, self).__init__(page) + self._route = None + self._route_metadata = None + + def _get_uri(self): + page = self._page + if self._route is None: + # TODO: this is not quite correct, as we're missing parts of the + # route metadata if the current page is a taxonomy page. + route_metadata = create_route_metadata(page) + self._route = page.app.getRoute(page.source.name, route_metadata) + self._route_metadata = route_metadata + if self._route is None: + raise Exception("Can't get route for page: %s" % page.path) + return self._route.getUri(self._route_metadata) + + def _load(self): + page = self._page + page_url = self._get_uri() + _, slug = split_uri(page.app, page_url) + self._setValue('url', page_url) + self._setValue('slug', slug) + self._setValue( + 'timestamp', + time.mktime(page.datetime.timetuple())) + date_format = page.app.config.get('site/date_format') + if date_format: + self._setValue('date', page.datetime.strftime(date_format)) + self._setValue('mtime', page.path_mtime) + + assetor = Assetor(page, page_url) + self._setValue('assets', assetor) + + segment_names = page.config.get('segments') + for name in segment_names: + self._mapLoader(name, self._load_rendered_segment) + + def _load_rendered_segment(self, data, name): + do_render = True + eis = self._page.app.env.exec_info_stack + if eis is not None and eis.hasPage(self._page): + # This is the pagination data for the page that is currently + # being rendered! Inception! But this is possible... so just + # prevent infinite recursion. + do_render = False + + assert self is data + + if do_render: + uri = self._get_uri() + try: + from piecrust.rendering import ( + QualifiedPage, PageRenderingContext, + render_page_segments) + qp = QualifiedPage(self._page, self._route, + self._route_metadata) + ctx = PageRenderingContext(qp) + render_result = render_page_segments(ctx) + segs = render_result.segments + except Exception as e: + raise Exception( + "Error rendering segments for '%s'" % uri) from e + else: + segs = {} + for name in self._page.config.get('segments'): + segs[name] = "<unavailable: current page>" + + for k, v in segs.items(): + self._unmapLoader(k) + self._setValue(k, v) + + if 'content.abstract' in segs: + self._setValue('content', segs['content.abstract']) + self._setValue('has_more', True) + if name == 'content': + return segs['content.abstract'] + + return segs[name] +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/data/piecrustdata.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,38 @@ +import logging +from piecrust import APP_VERSION +from piecrust.data.debug import build_debug_info + + +logger = logging.getLogger(__name__) + + +class PieCrustData(object): + debug_render = ['version', 'url', 'branding', 'debug_info'] + debug_render_invoke = ['version', 'url', 'branding', 'debug_info'] + debug_render_redirect = {'debug_info': '_debugRenderDebugInfo'} + + def __init__(self): + self.version = APP_VERSION + self.url = 'http://bolt80.com/piecrust/' + self.branding = 'Baked with <em><a href="%s">PieCrust</a> %s</em>.' % ( + 'http://bolt80.com/piecrust/', APP_VERSION) + self._page = None + self._data = None + + @property + def debug_info(self): + if self._page is not None and self._data is not None: + try: + return build_debug_info(self._page, self._data) + except Exception as ex: + logger.exception(ex) + return ('An error occured while generating debug info. ' + 'Please check the logs.') + return '' + + def _enableDebugInfo(self, page, data): + self._page = page + self._data = data + + def _debugRenderDebugInfo(self): + return "The very thing you're looking at!"
--- a/piecrust/data/provider.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/data/provider.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,54 +1,37 @@ import time +import collections.abc from piecrust.data.iterators import PageIterator from piecrust.sources.array import ArraySource class DataProvider(object): - debug_render_dynamic = ['_debugRenderUserData'] - debug_render_invoke_dynamic = ['_debugRenderUserData'] + debug_render_dynamic = [] + debug_render_invoke_dynamic = [] - def __init__(self, source, page, user_data): + def __init__(self, source, page, override): if source.app is not page.app: raise Exception("The given source and page don't belong to " "the same application.") self._source = source self._page = page - self._user_data = user_data - - def __getattr__(self, name): - if self._user_data is not None: - try: - return self._user_data[name] - except KeyError: - pass - raise AttributeError() - - def __getitem__(self, name): - if self._user_data is not None: - return self._user_data[name] - raise KeyError() - - def _debugRenderUserData(self): - if self._user_data: - return list(self._user_data.keys()) - return [] class IteratorDataProvider(DataProvider): PROVIDER_NAME = 'iterator' - debug_render_doc = """Provides a list of pages.""" + debug_render_doc_dynamic = ['_debugRenderDoc'] + debug_render_not_empty = True - def __init__(self, source, page, user_data): + def __init__(self, source, page, override): + super(IteratorDataProvider, self).__init__(source, page, override) + self._innerIt = None - if isinstance(user_data, IteratorDataProvider): + if isinstance(override, IteratorDataProvider): # Iterator providers can be chained, like for instance with # `site.pages` listing both the theme pages and the user site's # pages. - self._innerIt = user_data - user_data = None + self._innerIt = override - super(IteratorDataProvider, self).__init__(source, page, user_data) self._pages = PageIterator(source, current_page=page) self._pages._iter_event += self._onIteration self._ctx_set = False @@ -70,61 +53,68 @@ eis.current_page_info.render_ctx.addUsedSource(self._source.name) self._ctx_set = True + def _debugRenderDoc(self): + return 'Provides a list of %d items' % len(self) -class BlogDataProvider(DataProvider): + +class BlogDataProvider(DataProvider, collections.abc.Mapping): PROVIDER_NAME = 'blog' debug_render_doc = """Provides a list of blog posts and yearly/monthly archives.""" - debug_render = ['posts', 'years', 'months'] debug_render_dynamic = (['_debugRenderTaxonomies'] + DataProvider.debug_render_dynamic) - def __init__(self, source, page, user_data): - super(BlogDataProvider, self).__init__(source, page, user_data) + def __init__(self, source, page, override): + super(BlogDataProvider, self).__init__(source, page, override) self._yearly = None self._monthly = None self._taxonomies = {} self._ctx_set = False - def __getattr__(self, name): - if self._source.app.getTaxonomy(name) is not None: + def __getitem__(self, name): + if name == 'posts': + return self._posts() + elif name == 'years': + return self._buildYearlyArchive() + elif name == 'months': + return self._buildMonthlyArchive() + elif self._source.app.getTaxonomy(name) is not None: return self._buildTaxonomy(name) - return super(BlogDataProvider, self).__getattr__(name) + raise KeyError("No such item: %s" % name) + + def __iter__(self): + keys = ['posts', 'years', 'months'] + keys += [t.name for t in self._source.app.taxonomies] + return iter(keys) - @property - def posts(self): + def __len__(self): + return 3 + len(self._source.app.taxonomies) + + def _debugRenderTaxonomies(self): + return [t.name for t in self._source.app.taxonomies] + + def _posts(self): it = PageIterator(self._source, current_page=self._page) it._iter_event += self._onIteration return it - @property - def years(self): - return self._buildYearlyArchive() - - @property - def months(self): - return self._buildMonthlyArchive() - - def _debugRenderTaxonomies(self): - return [t.name for t in self._source.app.taxonomies] - def _buildYearlyArchive(self): if self._yearly is not None: return self._yearly self._yearly = [] + yearly_index = {} for post in self._source.getPages(): year = post.datetime.strftime('%Y') - posts_this_year = next( - filter(lambda y: y.name == year, self._yearly), - None) + posts_this_year = yearly_index.get(year) if posts_this_year is None: timestamp = time.mktime( (post.datetime.year, 1, 1, 0, 0, 0, 0, 0, -1)) posts_this_year = BlogArchiveEntry(self._page, year, timestamp) self._yearly.append(posts_this_year) + yearly_index[year] = posts_this_year posts_this_year._data_source.append(post) self._yearly = sorted(self._yearly,
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/data/providersdata.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,39 @@ +import re +import collections.abc + + +re_endpoint_sep = re.compile(r'[\/\.]') + + +class DataProvidersData(collections.abc.Mapping): + def __init__(self, page): + self._page = page + self._dict = None + + def __getitem__(self, name): + self._load() + return self._dict[name] + + def __iter__(self): + self._load() + return iter(self._dict) + + def __len__(self): + self._load() + return len(self._dict) + + def _load(self): + if self._dict is not None: + return + + self._dict = {} + for source in self._page.app.sources: + endpoint_bits = re_endpoint_sep.split(source.data_endpoint) + endpoint = self._dict + for e in endpoint_bits[:-1]: + if e not in endpoint: + endpoint[e] = {} + endpoint = endpoint[e] + override = endpoint.get(endpoint_bits[-1]) + provider = source.buildDataProvider(self._page, override) + endpoint[endpoint_bits[-1]] = provider
--- a/piecrust/environment.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/environment.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,83 +1,14 @@ -import re import time -import json import logging -import hashlib -import threading import contextlib -import collections -import repoze.lru +from piecrust.cache import MemCache logger = logging.getLogger(__name__) -re_fs_cache_key = re.compile(r'[^\d\w\-\._]+') - - -def _make_fs_cache_key(key): - return hashlib.md5(key.encode('utf8')).hexdigest() - - -class MemCache(object): - """ Simple memory cache. It can be backed by a simple file-system - cache, but items need to be JSON-serializable to do this. - """ - def __init__(self, size=2048): - self.cache = repoze.lru.LRUCache(size) - self.fs_cache = None - self._invalidated_fs_items = set() - self._lock = threading.RLock() - - @contextlib.contextmanager - def startBatchGet(self): - logger.debug("Starting batch cache operation.") - with self._lock: - yield - logger.debug("Ending batch cache operation.") - - def invalidate(self, key): - with self._lock: - logger.debug("Invalidating cache item '%s'." % key) - self.cache.invalidate(key) - if self.fs_cache: - logger.debug("Invalidating FS cache item '%s'." % key) - fs_key = _make_fs_cache_key(key) - self._invalidated_fs_items.add(fs_key) - - def get(self, key, item_maker, fs_cache_time=None): - item = self.cache.get(key) - if item is None: - logger.debug("Acquiring lock for: %s" % key) - with self._lock: - item = self.cache.get(key) - if item is None: - if (self.fs_cache is not None and - fs_cache_time is not None): - # Try first from the file-system cache. - fs_key = _make_fs_cache_key(key) - if (fs_key not in self._invalidated_fs_items and - self.fs_cache.isValid(fs_key, fs_cache_time)): - logger.debug("'%s' found in file-system cache." % - key) - item_raw = self.fs_cache.read(fs_key) - item = json.loads( - item_raw, - object_pairs_hook=collections.OrderedDict) - self.cache.put(key, item) - return item - - # Look into the mem-cache. - logger.debug("'%s' not found in cache, must build." % key) - item = item_maker() - self.cache.put(key, item) - - # Save to the file-system if needed. - if (self.fs_cache is not None and - fs_cache_time is not None): - item_raw = json.dumps(item) - self.fs_cache.write(fs_key, item_raw) - return item +class AbortedSourceUseError(Exception): + pass class ExecutionInfo(object): @@ -85,10 +16,10 @@ self.page = page self.render_ctx = render_ctx self.was_cache_valid = False - self.start_time = time.clock() + self.start_time = time.perf_counter() -class ExecutionInfoStack(threading.local): +class ExecutionInfoStack(object): def __init__(self): self._page_stack = [] @@ -123,6 +54,7 @@ class Environment(object): def __init__(self): + self.app = None self.start_time = None self.exec_info_stack = ExecutionInfoStack() self.was_cache_cleaned = False @@ -131,14 +63,52 @@ self.rendered_segments_repository = MemCache() self.fs_caches = { 'renders': self.rendered_segments_repository} + self.fs_cache_only_for_main_page = False + self.abort_source_use = False + self._default_layout_extensions = None + self._timers = {} + + @property + def default_layout_extensions(self): + if self._default_layout_extensions is not None: + return self._default_layout_extensions + + if self.app is None: + raise Exception("This environment has not been initialized yet.") + + from piecrust.rendering import get_template_engine + dte = get_template_engine(self.app, None) + self._default_layout_extensions = ['.' + e.lstrip('.') + for e in dte.EXTENSIONS] + return self._default_layout_extensions def initialize(self, app): - self.start_time = time.clock() + self.app = app + self.start_time = time.perf_counter() self.exec_info_stack.clear() self.was_cache_cleaned = False self.base_asset_url_format = '%uri%' + self._onSubCacheDirChanged(app) + def registerTimer(self, category, *, raise_if_registered=True): + if raise_if_registered and category in self._timers: + raise Exception("Timer '%s' has already been registered." % + category) + self._timers[category] = 0 + + @contextlib.contextmanager + def timerScope(self, category): + start = time.perf_counter() + yield + self._timers[category] += time.perf_counter() - start + + def stepTimer(self, category, value): + self._timers[category] += value + + def stepTimerSince(self, category, since): + self.stepTimer(category, time.perf_counter() - since) + def _onSubCacheDirChanged(self, app): for name, repo in self.fs_caches.items(): cache = app.cache.getCache(name)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/fastpickle.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,221 @@ +import sys +import json +import datetime +import collections + + +def pickle(obj): + data = _pickle_object(obj) + data = json.dumps(data, indent=None, separators=(',', ':')) + return data.encode('utf8') + + +def unpickle(data): + data = data.decode('utf8') + data = json.loads(data) + return _unpickle_object(data) + + +_PICKLING = 0 +_UNPICKLING = 1 + +_identity_dispatch = object() + + +def _tuple_convert(obj, func, op): + if op == _PICKLING: + return ['__type__:tuple'] + [func(c) for c in obj] + elif op == _UNPICKLING: + return tuple([func(c) for c in obj[1:]]) + + +def _list_convert(obj, func, op): + return [func(c) for c in obj] + + +def _dict_convert(obj, func, op): + res = {} + for k, v in obj.items(): + res[k] = func(v) + return res + + +def _ordered_dict_convert(obj, func, op): + if op == _PICKLING: + res = {'__type__': 'OrderedDict'} + for k, v in obj.items(): + res[k] = func(v) + return res + elif op == _UNPICKLING: + res = collections.OrderedDict() + for k, v in obj.items(): + res[k] = func(v) + return res + + +def _set_convert(obj, func, op): + if op == _PICKLING: + return ['__type__:set'] + [func(c) for c in obj] + elif op == _UNPICKLING: + return set([func(c) for c in obj[1:]]) + + +def _date_convert(obj, func, op): + if op == _PICKLING: + return {'__class__': 'date', + 'year': obj.year, + 'month': obj.month, + 'day': obj.day} + elif op == _UNPICKLING: + return datetime.date( + obj['year'], obj['month'], obj['day']) + + +def _datetime_convert(obj, func, op): + if op == _PICKLING: + return {'__class__': 'datetime', + 'year': obj.year, + 'month': obj.month, + 'day': obj.day, + 'hour': obj.hour, + 'minute': obj.minute, + 'second': obj.second, + 'microsecond': obj.microsecond} + elif op == _UNPICKLING: + return datetime.datetime( + obj['year'], obj['month'], obj['day'], + obj['hour'], obj['minute'], obj['second'], obj['microsecond']) + + +def _time_convert(obj, func, op): + if op == _PICKLING: + return {'__class__': 'time', + 'hour': obj.hour, + 'minute': obj.minute, + 'second': obj.second, + 'microsecond': obj.microsecond} + elif op == _UNPICKLING: + return datetime.time( + obj['hour'], obj['minute'], obj['second'], obj['microsecond']) + + +_type_convert = { + type(None): _identity_dispatch, + bool: _identity_dispatch, + int: _identity_dispatch, + float: _identity_dispatch, + str: _identity_dispatch, + datetime.date: _date_convert, + datetime.datetime: _datetime_convert, + datetime.time: _time_convert, + tuple: _tuple_convert, + list: _list_convert, + dict: _dict_convert, + set: _set_convert, + collections.OrderedDict: _ordered_dict_convert, + } + + +_type_unconvert = { + type(None): _identity_dispatch, + bool: _identity_dispatch, + int: _identity_dispatch, + float: _identity_dispatch, + str: _identity_dispatch, + 'date': _date_convert, + 'datetime': _datetime_convert, + 'time': _time_convert, + } + + +_collection_unconvert = { + '__type__:tuple': _tuple_convert, + '__type__:set': _set_convert, + } + + +_mapping_unconvert = { + 'OrderedDict': _ordered_dict_convert + } + + +def _pickle_object(obj): + t = type(obj) + conv = _type_convert.get(t) + + # Object doesn't need conversion? + if conv is _identity_dispatch: + return obj + + # Object has special conversion? + if conv is not None: + return conv(obj, _pickle_object, _PICKLING) + + # Use instance dictionary, or a custom state. + getter = getattr(obj, '__getstate__', None) + if getter is not None: + state = getter() + else: + state = obj.__dict__ + + state = _dict_convert(state, _pickle_object, _PICKLING) + state['__class__'] = obj.__class__.__name__ + state['__module__'] = obj.__class__.__module__ + + return state + + +def _unpickle_object(state): + t = type(state) + conv = _type_unconvert.get(t) + + # Object doesn't need conversion? + if conv is _identity_dispatch: + return state + + # Try collection or mapping conversion. + if t is list: + try: + col_type = state[0] + if not isinstance(col_type, str): + col_type = None + except IndexError: + col_type = None + if col_type is not None: + conv = _collection_unconvert.get(col_type) + if conv is not None: + return conv(state, _unpickle_object, _UNPICKLING) + return _list_convert(state, _unpickle_object, _UNPICKLING) + + assert t is dict + + # Custom mapping type? + map_type = state.get('__type__') + if map_type: + conv = _mapping_unconvert.get(map_type) + return conv(state, _unpickle_object, _UNPICKLING) + + # Class instance or other custom type. + class_name = state.get('__class__') + if class_name is None: + return _dict_convert(state, _unpickle_object, _UNPICKLING) + + conv = _type_unconvert.get(class_name) + if conv is not None: + return conv(state, _unpickle_object, _UNPICKLING) + + mod_name = state['__module__'] + mod = sys.modules[mod_name] + class_def = getattr(mod, class_name) + obj = class_def.__new__(class_def) + + del state['__class__'] + del state['__module__'] + attr_names = list(state.keys()) + for name in attr_names: + state[name] = _unpickle_object(state[name]) + + obj.__dict__.update(state) + + return obj +
--- a/piecrust/formatting/base.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/formatting/base.py Sat Jul 11 20:33:55 2015 -0700 @@ -10,6 +10,7 @@ def __init__(self): self.priority = PRIORITY_NORMAL + self.enabled = True def initialize(self, app): self.app = app
--- a/piecrust/formatting/markdownformatter.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/formatting/markdownformatter.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,4 +1,4 @@ -from markdown import markdown +from markdown import Markdown from piecrust.formatting.base import Formatter @@ -8,15 +8,15 @@ def __init__(self): super(MarkdownFormatter, self).__init__() - self._extensions = None + self._formatter = None def render(self, format_name, txt): assert format_name in self.FORMAT_NAMES self._ensureInitialized() - return markdown(txt, extensions=self._extensions) + return self._formatter.reset().convert(txt) def _ensureInitialized(self): - if self._extensions is not None: + if self._formatter is not None: return config = self.app.config.get('markdown') @@ -34,5 +34,6 @@ # Compatibility with PieCrust 1.x if config.get('use_markdown_extra'): extensions.append('extra') - self._extensions = extensions + self._formatter = Markdown(extensions=extensions) +
--- a/piecrust/formatting/textileformatter.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/formatting/textileformatter.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,4 +1,3 @@ -from textile import textile from piecrust.formatting.base import Formatter @@ -7,6 +6,7 @@ OUTPUT_FORMAT = 'html' def render(self, format_name, text): + from textile import textile assert format_name in self.FORMAT_NAMES return textile(text)
--- a/piecrust/page.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/page.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,7 +1,6 @@ import re import sys import json -import codecs import os.path import hashlib import logging @@ -9,7 +8,8 @@ import dateutil.parser import collections from werkzeug.utils import cached_property -from piecrust.configuration import (Configuration, ConfigurationError, +from piecrust.configuration import ( + Configuration, ConfigurationError, parse_config_header) from piecrust.routing import IRouteMetadataProvider @@ -198,19 +198,18 @@ class ContentSegment(object): debug_render_func = 'debug_render' - def __init__(self, content=None, fmt=None): + def __init__(self): self.parts = [] - if content is not None: - self.parts.append(ContentSegmentPart(content, fmt)) def debug_render(self): return '\n'.join([p.content for p in self.parts]) class ContentSegmentPart(object): - def __init__(self, content, fmt=None, line=-1): + def __init__(self, content, fmt=None, offset=-1, line=-1): self.content = content self.fmt = fmt + self.offset = offset self.line = line def __str__(self): @@ -222,7 +221,8 @@ for key, seg_data in data.items(): seg = ContentSegment() for p_data in seg_data: - part = ContentSegmentPart(p_data['c'], p_data['f'], p_data['l']) + part = ContentSegmentPart(p_data['c'], p_data['f'], p_data['o'], + p_data['l']) seg.parts.append(part) segments[key] = seg return segments @@ -233,7 +233,8 @@ for key, seg in segments.items(): seg_data = [] for part in seg.parts: - p_data = {'c': part.content, 'f': part.fmt, 'l': part.line} + p_data = {'c': part.content, 'f': part.fmt, 'o': part.offset, + 'l': part.line} seg_data.append(p_data) data[key] = seg_data return data @@ -241,9 +242,11 @@ def load_page(app, path, path_mtime=None): try: - return _do_load_page(app, path, path_mtime) + with app.env.timerScope('PageLoad'): + return _do_load_page(app, path, path_mtime) except Exception as e: - logger.exception("Error loading page: %s" % + logger.exception( + "Error loading page: %s" % os.path.relpath(path, app.root_dir)) _, __, traceback = sys.exc_info() raise PageLoadingError(path, e).with_traceback(traceback) @@ -255,20 +258,22 @@ cache_path = hashlib.md5(path.encode('utf8')).hexdigest() + '.json' page_time = path_mtime or os.path.getmtime(path) if cache.isValid(cache_path, page_time): - cache_data = json.loads(cache.read(cache_path), + cache_data = json.loads( + cache.read(cache_path), object_pairs_hook=collections.OrderedDict) - config = PageConfiguration(values=cache_data['config'], + config = PageConfiguration( + values=cache_data['config'], validate=False) content = json_load_segments(cache_data['content']) return config, content, True # Nope, load the page from the source file. logger.debug("Loading page configuration from: %s" % path) - with codecs.open(path, 'r', 'utf-8') as fp: + with open(path, 'r', encoding='utf-8') as fp: raw = fp.read() header, offset = parse_config_header(raw) - if not 'format' in header: + if 'format' not in header: auto_formats = app.config.get('site/auto_formats') name, ext = os.path.splitext(path) header['format'] = auto_formats.get(ext, None) @@ -279,7 +284,7 @@ # Save to the cache. cache_data = { - 'config': config.get(), + 'config': config.getAll(), 'content': json_save_segments(content)} cache.write(cache_path, json.dumps(cache_data)) @@ -352,7 +357,8 @@ # First part, before the first format change. part_text = raw[start:matches[0].start()] parts.append( - ContentSegmentPart(part_text, first_part_fmt, line_offset)) + ContentSegmentPart(part_text, first_part_fmt, start, + line_offset)) line_offset += _count_lines(part_text) for i in range(1, num_matches): @@ -361,17 +367,20 @@ part_text = raw[m1.end() + 1:m2.start()] parts.append( ContentSegmentPart( - part_text, m1.group('fmt'), line_offset)) + part_text, m1.group('fmt'), m1.end() + 1, + line_offset)) line_offset += _count_lines(part_text) lastm = matches[-1] part_text = raw[lastm.end() + 1:end] parts.append(ContentSegmentPart( - part_text, lastm.group('fmt'), line_offset)) + part_text, lastm.group('fmt'), lastm.end() + 1, + line_offset)) return parts, line_offset else: part_text = raw[start:end] - parts = [ContentSegmentPart(part_text, first_part_fmt, line_offset)] + parts = [ContentSegmentPart(part_text, first_part_fmt, start, + line_offset)] return parts, line_offset
--- a/piecrust/plugins/base.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/plugins/base.py Sat Jul 11 20:33:55 2015 -0700 @@ -50,17 +50,23 @@ def getFormatters(self): return self._getPluginComponents( - 'getFormatters', True, order_key=lambda f: f.priority) + 'getFormatters', + initialize=True, register_timer=True, + order_key=lambda f: f.priority) def getTemplateEngines(self): - return self._getPluginComponents('getTemplateEngines', True) + return self._getPluginComponents( + 'getTemplateEngines', + initialize=True, register_timer=True) def getDataProviders(self): return self._getPluginComponents('getDataProviders') def getProcessors(self): return self._getPluginComponents( - 'getProcessors', True, order_key=lambda p: p.priority) + 'getProcessors', + initialize=True, register_timer=True, + order_key=lambda p: p.priority) def getImporters(self): return self._getPluginComponents('getImporters') @@ -86,8 +92,9 @@ to_install = self.app.config.get('site/plugins') if to_install: - for p in to_install: - self._loadPlugin(p) + for name in to_install: + plugin = self._loadPlugin(name) + self._plugins.append(plugin) for plugin in self._plugins: plugin.initialize(self.app) @@ -113,9 +120,11 @@ (plugin_name, ex)) return - self._plugins.append(plugin) + return plugin - def _getPluginComponents(self, name, initialize=False, order_key=None): + def _getPluginComponents(self, name, *, + initialize=False, register_timer=False, + order_key=None): if name in self._componentCache: return self._componentCache[name] @@ -123,10 +132,15 @@ for plugin in self.plugins: plugin_components = getattr(plugin, name)() all_components += plugin_components + if initialize: for comp in plugin_components: comp.initialize(self.app) + if register_timer: + for comp in plugin_components: + self.app.env.registerTimer(comp.__class__.__name__) + if order_key is not None: all_components.sort(key=order_key)
--- a/piecrust/processing/base.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/processing/base.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,35 +1,36 @@ -import re -import time import shutil import os.path import logging -import hashlib -import threading -from queue import Queue, Empty -from piecrust.chefutil import format_timed -from piecrust.processing.records import ( - ProcessorPipelineRecordEntry, TransitionalProcessorPipelineRecord, - FLAG_PREPARED, FLAG_PROCESSED, FLAG_OVERRIDEN, - FLAG_BYPASSED_STRUCTURED_PROCESSING) -from piecrust.processing.tree import ( - ProcessingTreeBuilder, ProcessingTreeRunner, - ProcessingTreeError, ProcessorError, - STATE_DIRTY, - print_node, get_node_name_tree) logger = logging.getLogger(__name__) -re_ansicolors = re.compile('\033\\[\d+m') - - PRIORITY_FIRST = -1 PRIORITY_NORMAL = 0 PRIORITY_LAST = 1 -split_processor_names_re = re.compile(r'[ ,]+') +class PipelineContext(object): + def __init__(self, worker_id, app, out_dir, tmp_dir, force=None): + self.worker_id = worker_id + self.app = app + self.out_dir = out_dir + self.tmp_dir = tmp_dir + self.force = force + self.record = None + self._additional_ignore_patterns = [] + + @property + def is_first_worker(self): + return self.worker_id == 0 + + @property + def is_pipeline_process(self): + return self.worker_id < 0 + + def addIgnorePatterns(self, patterns): + self._additional_ignore_patterns += patterns class Processor(object): @@ -43,10 +44,10 @@ def initialize(self, app): self.app = app - def onPipelineStart(self, pipeline): + def onPipelineStart(self, ctx): pass - def onPipelineEnd(self, pipeline): + def onPipelineEnd(self, ctx): pass def matches(self, path): @@ -117,341 +118,3 @@ return self.stderr_data -class ProcessingContext(object): - def __init__(self, base_dir, mount_info, job_queue, record=None): - self.base_dir = base_dir - self.mount_info = mount_info - self.job_queue = job_queue - self.record = record - - -class ProcessorPipeline(object): - def __init__(self, app, out_dir, force=False): - assert app and out_dir - self.app = app - self.out_dir = out_dir - self.force = force - - tmp_dir = app.sub_cache_dir - if not tmp_dir: - import tempfile - tmp_dir = os.path.join(tempfile.gettempdir(), 'piecrust') - self.tmp_dir = os.path.join(tmp_dir, 'proc') - - baker_params = app.config.get('baker') or {} - - assets_dirs = baker_params.get('assets_dirs', app.assets_dirs) - self.mounts = make_mount_infos(assets_dirs, self.app.root_dir) - - self.num_workers = baker_params.get('workers', 4) - - ignores = baker_params.get('ignore', []) - ignores += [ - '_cache', '_counter', - 'theme_info.yml', - '.DS_Store', 'Thumbs.db', - '.git*', '.hg*', '.svn'] - self.skip_patterns = make_re(ignores) - self.force_patterns = make_re(baker_params.get('force', [])) - - self.processors = app.plugin_loader.getProcessors() - - def addSkipPatterns(self, patterns): - self.skip_patterns += make_re(patterns) - - def filterProcessors(self, authorized_names): - self.processors = self.getFilteredProcessors(authorized_names) - - def getFilteredProcessors(self, authorized_names): - if not authorized_names or authorized_names == 'all': - return self.processors - - if isinstance(authorized_names, str): - authorized_names = split_processor_names_re.split(authorized_names) - - procs = [] - has_star = 'all' in authorized_names - for p in self.processors: - for name in authorized_names: - if name == p.PROCESSOR_NAME: - procs.append(p) - break - if name == ('-%s' % p.PROCESSOR_NAME): - break - else: - if has_star: - procs.append(p) - return procs - - def run(self, src_dir_or_file=None, *, - delete=True, previous_record=None, save_record=True): - # Invoke pre-processors. - for proc in self.processors: - proc.onPipelineStart(self) - - # Sort our processors again in case the pre-process step involved - # patching the processors with some new ones. - self.processors.sort(key=lambda p: p.priority) - - # Create the pipeline record. - record = TransitionalProcessorPipelineRecord() - record_cache = self.app.cache.getCache('proc') - record_name = ( - hashlib.md5(self.out_dir.encode('utf8')).hexdigest() + - '.record') - if previous_record: - record.setPrevious(previous_record) - elif not self.force and record_cache.has(record_name): - t = time.clock() - record.loadPrevious(record_cache.getCachePath(record_name)) - logger.debug(format_timed(t, 'loaded previous bake record', - colored=False)) - logger.debug("Got %d entries in process record." % - len(record.previous.entries)) - - # Create the workers. - pool = [] - queue = Queue() - abort = threading.Event() - pipeline_lock = threading.Lock() - for i in range(self.num_workers): - ctx = ProcessingWorkerContext(self, record, - queue, abort, pipeline_lock) - worker = ProcessingWorker(i, ctx) - worker.start() - pool.append(worker) - - if src_dir_or_file is not None: - # Process only the given path. - # Find out what mount point this is in. - for name, info in self.mounts.items(): - path = info['path'] - if src_dir_or_file[:len(path)] == path: - base_dir = path - mount_info = info - break - else: - known_roots = [i['path'] for i in self.mounts.values()] - raise Exception("Input path '%s' is not part of any known " - "mount point: %s" % - (src_dir_or_file, known_roots)) - - ctx = ProcessingContext(base_dir, mount_info, queue, record) - logger.debug("Initiating processing pipeline on: %s" % src_dir_or_file) - if os.path.isdir(src_dir_or_file): - self.processDirectory(ctx, src_dir_or_file) - elif os.path.isfile(src_dir_or_file): - self.processFile(ctx, src_dir_or_file) - - else: - # Process everything. - for name, info in self.mounts.items(): - path = info['path'] - ctx = ProcessingContext(path, info, queue, record) - logger.debug("Initiating processing pipeline on: %s" % path) - self.processDirectory(ctx, path) - - # Wait on all workers. - record.current.success = True - for w in pool: - w.join() - record.current.success &= w.success - if abort.is_set(): - raise Exception("Worker pool was aborted.") - - # Handle deletions. - if delete: - for path, reason in record.getDeletions(): - logger.debug("Removing '%s': %s" % (path, reason)) - try: - os.remove(path) - except FileNotFoundError: - pass - logger.info('[delete] %s' % path) - - # Invoke post-processors. - for proc in self.processors: - proc.onPipelineEnd(self) - - # Finalize the process record. - record.current.process_time = time.time() - record.current.out_dir = self.out_dir - record.collapseRecords() - - # Save the process record. - if save_record: - t = time.clock() - record.saveCurrent(record_cache.getCachePath(record_name)) - logger.debug(format_timed(t, 'saved bake record', colored=False)) - - return record.detach() - - def processDirectory(self, ctx, start_dir): - for dirpath, dirnames, filenames in os.walk(start_dir): - rel_dirpath = os.path.relpath(dirpath, start_dir) - dirnames[:] = [d for d in dirnames - if not re_matchany(d, self.skip_patterns, rel_dirpath)] - - for filename in filenames: - if re_matchany(filename, self.skip_patterns, rel_dirpath): - continue - self.processFile(ctx, os.path.join(dirpath, filename)) - - def processFile(self, ctx, path): - logger.debug("Queuing: %s" % path) - job = ProcessingWorkerJob(ctx.base_dir, ctx.mount_info, path) - ctx.job_queue.put_nowait(job) - - -class ProcessingWorkerContext(object): - def __init__(self, pipeline, record, - work_queue, abort_event, pipeline_lock): - self.pipeline = pipeline - self.record = record - self.work_queue = work_queue - self.abort_event = abort_event - self.pipeline_lock = pipeline_lock - - -class ProcessingWorkerJob(object): - def __init__(self, base_dir, mount_info, path): - self.base_dir = base_dir - self.mount_info = mount_info - self.path = path - - -class ProcessingWorker(threading.Thread): - def __init__(self, wid, ctx): - super(ProcessingWorker, self).__init__() - self.wid = wid - self.ctx = ctx - self.success = True - - def run(self): - while(not self.ctx.abort_event.is_set()): - try: - job = self.ctx.work_queue.get(True, 0.1) - except Empty: - logger.debug("[%d] No more work... shutting down." % self.wid) - break - - try: - success = self._unsafeRun(job) - logger.debug("[%d] Done with file." % self.wid) - self.ctx.work_queue.task_done() - self.success &= success - except Exception as ex: - self.ctx.abort_event.set() - self.success = False - logger.error("[%d] Critical error, aborting." % self.wid) - logger.exception(ex) - break - - def _unsafeRun(self, job): - start_time = time.clock() - pipeline = self.ctx.pipeline - record = self.ctx.record - - rel_path = os.path.relpath(job.path, job.base_dir) - previous_entry = record.getPreviousEntry(rel_path) - - record_entry = ProcessorPipelineRecordEntry(job.base_dir, rel_path) - record.addEntry(record_entry) - - # Figure out if a previously processed file is overriding this one. - # This can happen if a theme file (processed via a mount point) - # is overridden in the user's website. - if record.current.hasOverrideEntry(rel_path): - record_entry.flags |= FLAG_OVERRIDEN - logger.info(format_timed(start_time, - '%s [not baked, overridden]' % rel_path)) - return True - - processors = pipeline.getFilteredProcessors( - job.mount_info['processors']) - try: - builder = ProcessingTreeBuilder(processors) - tree_root = builder.build(rel_path) - record_entry.flags |= FLAG_PREPARED - except ProcessingTreeError as ex: - msg = str(ex) - logger.error("Error preparing %s:\n%s" % (rel_path, msg)) - while ex: - record_entry.errors.append(str(ex)) - ex = ex.__cause__ - return False - - print_node(tree_root, recursive=True) - leaves = tree_root.getLeaves() - record_entry.rel_outputs = [l.path for l in leaves] - record_entry.proc_tree = get_node_name_tree(tree_root) - if tree_root.getProcessor().is_bypassing_structured_processing: - record_entry.flags |= FLAG_BYPASSED_STRUCTURED_PROCESSING - - force = (pipeline.force or previous_entry is None or - not previous_entry.was_processed_successfully) - if not force: - force = re_matchany(rel_path, pipeline.force_patterns) - - if force: - tree_root.setState(STATE_DIRTY, True) - - try: - runner = ProcessingTreeRunner( - job.base_dir, pipeline.tmp_dir, - pipeline.out_dir, self.ctx.pipeline_lock) - if runner.processSubTree(tree_root): - record_entry.flags |= FLAG_PROCESSED - logger.info(format_timed( - start_time, "[%d] %s" % (self.wid, rel_path))) - return True - except ProcessingTreeError as ex: - msg = str(ex) - if isinstance(ex, ProcessorError): - msg = str(ex.__cause__) - logger.error("Error processing %s:\n%s" % (rel_path, msg)) - while ex: - msg = re_ansicolors.sub('', str(ex)) - record_entry.errors.append(msg) - ex = ex.__cause__ - return False - - -def make_mount_infos(mounts, root_dir): - if isinstance(mounts, list): - mounts = {m: {} for m in mounts} - - for name, info in mounts.items(): - if not isinstance(info, dict): - raise Exception("Asset directory info for '%s' is not a " - "dictionary." % name) - info.setdefault('processors', 'all -uglifyjs -cleancss') - info['path'] = os.path.join(root_dir, name) - - return mounts - - -def make_re(patterns): - re_patterns = [] - for pat in patterns: - if pat[0] == '/' and pat[-1] == '/' and len(pat) > 2: - re_patterns.append(pat[1:-1]) - else: - escaped_pat = (re.escape(pat) - .replace(r'\*', r'[^/\\]*') - .replace(r'\?', r'[^/\\]')) - re_patterns.append(escaped_pat) - return [re.compile(p) for p in re_patterns] - - -def re_matchany(filename, patterns, dirname=None): - if dirname and dirname != '.': - filename = os.path.join(dirname, filename) - - # skip patterns use a forward slash regardless of the platform. - filename = filename.replace('\\', '/') - for pattern in patterns: - if pattern.search(filename): - return True - return False -
--- a/piecrust/processing/compass.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/processing/compass.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,7 +1,6 @@ import os import os.path import logging -import platform import subprocess from piecrust.processing.base import Processor, PRIORITY_FIRST from piecrust.uriutil import multi_replace
--- a/piecrust/processing/less.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/processing/less.py Sat Jul 11 20:33:55 2015 -0700 @@ -24,7 +24,8 @@ def onPipelineStart(self, pipeline): self._map_dir = os.path.join(pipeline.tmp_dir, 'less') - if not os.path.isdir(self._map_dir): + if (pipeline.is_first_worker and + not os.path.isdir(self._map_dir)): os.makedirs(self._map_dir) def getDependencies(self, path): @@ -42,6 +43,7 @@ # Get the sources, but make all paths absolute. sources = dep_map.get('sources') path_dir = os.path.dirname(path) + def _makeAbs(p): return os.path.join(path_dir, p) deps = list(map(_makeAbs, sources))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/processing/pipeline.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,297 @@ +import os +import os.path +import re +import time +import hashlib +import logging +import multiprocessing +from piecrust.chefutil import format_timed, format_timed_scope +from piecrust.processing.base import PipelineContext +from piecrust.processing.records import ( + ProcessorPipelineRecordEntry, TransitionalProcessorPipelineRecord, + FLAG_PROCESSED) +from piecrust.processing.worker import ( + ProcessingWorkerJob, + get_filtered_processors) + + +logger = logging.getLogger(__name__) + + +class _ProcessingContext(object): + def __init__(self, jobs, record, base_dir, mount_info): + self.jobs = jobs + self.record = record + self.base_dir = base_dir + self.mount_info = mount_info + + +class ProcessorPipeline(object): + def __init__(self, app, out_dir, force=False): + assert app and out_dir + self.app = app + self.out_dir = out_dir + self.force = force + + tmp_dir = app.sub_cache_dir + if not tmp_dir: + import tempfile + tmp_dir = os.path.join(tempfile.gettempdir(), 'piecrust') + self.tmp_dir = os.path.join(tmp_dir, 'proc') + + baker_params = app.config.get('baker') or {} + + assets_dirs = baker_params.get('assets_dirs', app.assets_dirs) + self.mounts = make_mount_infos(assets_dirs, self.app.root_dir) + + self.num_workers = baker_params.get( + 'workers', multiprocessing.cpu_count()) + + ignores = baker_params.get('ignore', []) + ignores += [ + '_cache', '_counter', + 'theme_info.yml', + '.DS_Store', 'Thumbs.db', + '.git*', '.hg*', '.svn'] + self.ignore_patterns = make_re(ignores) + self.force_patterns = make_re(baker_params.get('force', [])) + + # Those things are mostly for unit-testing. + self.enabled_processors = None + self.additional_processors = None + + def addIgnorePatterns(self, patterns): + self.ignore_patterns += make_re(patterns) + + def run(self, src_dir_or_file=None, *, + delete=True, previous_record=None, save_record=True): + start_time = time.perf_counter() + + # Get the list of processors for this run. + processors = self.app.plugin_loader.getProcessors() + if self.enabled_processors is not None: + logger.debug("Filtering processors to: %s" % + self.enabled_processors) + processors = get_filtered_processors(processors, + self.enabled_processors) + if self.additional_processors is not None: + logger.debug("Adding %s additional processors." % + len(self.additional_processors)) + for proc in self.additional_processors: + self.app.env.registerTimer(proc.__class__.__name__, + raise_if_registered=False) + proc.initialize(self.app) + processors.append(proc) + + # Invoke pre-processors. + pipeline_ctx = PipelineContext(-1, self.app, self.out_dir, + self.tmp_dir, self.force) + for proc in processors: + proc.onPipelineStart(pipeline_ctx) + + # Pre-processors can define additional ignore patterns. + self.ignore_patterns += make_re( + pipeline_ctx._additional_ignore_patterns) + + # Create the pipeline record. + record = TransitionalProcessorPipelineRecord() + record_cache = self.app.cache.getCache('proc') + record_name = ( + hashlib.md5(self.out_dir.encode('utf8')).hexdigest() + + '.record') + if previous_record: + record.setPrevious(previous_record) + elif not self.force and record_cache.has(record_name): + with format_timed_scope(logger, 'loaded previous bake record', + level=logging.DEBUG, colored=False): + record.loadPrevious(record_cache.getCachePath(record_name)) + logger.debug("Got %d entries in process record." % + len(record.previous.entries)) + record.current.success = True + record.current.processed_count = 0 + + # Work! + def _handler(res): + entry = record.getCurrentEntry(res.path) + assert entry is not None + entry.flags |= res.flags + entry.proc_tree = res.proc_tree + entry.rel_outputs = res.rel_outputs + if entry.flags & FLAG_PROCESSED: + record.current.processed_count += 1 + if res.errors: + entry.errors += res.errors + record.current.success = False + + rel_path = os.path.relpath(res.path, self.app.root_dir) + logger.error("Errors found in %s:" % rel_path) + for e in entry.errors: + logger.error(" " + e) + + jobs = [] + self._process(src_dir_or_file, record, jobs) + pool = self._createWorkerPool() + ar = pool.queueJobs(jobs, handler=_handler) + ar.wait() + + # Shutdown the workers and get timing information from them. + reports = pool.close() + record.current.timers = {} + for i in range(len(reports)): + timers = reports[i] + if timers is None: + continue + + worker_name = 'PipelineWorker_%d' % i + record.current.timers[worker_name] = {} + for name, val in timers['data'].items(): + main_val = record.current.timers.setdefault(name, 0) + record.current.timers[name] = main_val + val + record.current.timers[worker_name][name] = val + + # Invoke post-processors. + pipeline_ctx.record = record.current + for proc in processors: + proc.onPipelineEnd(pipeline_ctx) + + # Handle deletions. + if delete: + for path, reason in record.getDeletions(): + logger.debug("Removing '%s': %s" % (path, reason)) + try: + os.remove(path) + except FileNotFoundError: + pass + logger.info('[delete] %s' % path) + + # Finalize the process record. + record.current.process_time = time.time() + record.current.out_dir = self.out_dir + record.collapseRecords() + + # Save the process record. + if save_record: + with format_timed_scope(logger, 'saved bake record', + level=logging.DEBUG, colored=False): + record.saveCurrent(record_cache.getCachePath(record_name)) + + logger.info(format_timed( + start_time, + "processed %d assets." % record.current.processed_count)) + + return record.detach() + + def _process(self, src_dir_or_file, record, jobs): + if src_dir_or_file is not None: + # Process only the given path. + # Find out what mount point this is in. + for name, info in self.mounts.items(): + path = info['path'] + if src_dir_or_file[:len(path)] == path: + base_dir = path + mount_info = info + break + else: + known_roots = [i['path'] for i in self.mounts.values()] + raise Exception("Input path '%s' is not part of any known " + "mount point: %s" % + (src_dir_or_file, known_roots)) + + ctx = _ProcessingContext(jobs, record, base_dir, mount_info) + logger.debug("Initiating processing pipeline on: %s" % + src_dir_or_file) + if os.path.isdir(src_dir_or_file): + self._processDirectory(ctx, src_dir_or_file) + elif os.path.isfile(src_dir_or_file): + self._processFile(ctx, src_dir_or_file) + + else: + # Process everything. + for name, info in self.mounts.items(): + path = info['path'] + ctx = _ProcessingContext(jobs, record, path, info) + logger.debug("Initiating processing pipeline on: %s" % path) + self._processDirectory(ctx, path) + + def _processDirectory(self, ctx, start_dir): + for dirpath, dirnames, filenames in os.walk(start_dir): + rel_dirpath = os.path.relpath(dirpath, start_dir) + dirnames[:] = [d for d in dirnames + if not re_matchany( + d, self.ignore_patterns, rel_dirpath)] + + for filename in filenames: + if re_matchany(filename, self.ignore_patterns, rel_dirpath): + continue + self._processFile(ctx, os.path.join(dirpath, filename)) + + def _processFile(self, ctx, path): + # TODO: handle overrides between mount-points. + + entry = ProcessorPipelineRecordEntry(path) + ctx.record.addEntry(entry) + + previous_entry = ctx.record.getPreviousEntry(path) + force_this = (self.force or previous_entry is None or + not previous_entry.was_processed_successfully) + + job = ProcessingWorkerJob(ctx.base_dir, ctx.mount_info, path, + force=force_this) + ctx.jobs.append(job) + + def _createWorkerPool(self): + from piecrust.workerpool import WorkerPool + from piecrust.processing.worker import ( + ProcessingWorkerContext, ProcessingWorker) + + ctx = ProcessingWorkerContext( + self.app.root_dir, self.out_dir, self.tmp_dir, + self.force, self.app.debug) + ctx.enabled_processors = self.enabled_processors + ctx.additional_processors = self.additional_processors + + pool = WorkerPool( + worker_class=ProcessingWorker, + initargs=(ctx,)) + return pool + + +def make_mount_infos(mounts, root_dir): + if isinstance(mounts, list): + mounts = {m: {} for m in mounts} + + for name, info in mounts.items(): + if not isinstance(info, dict): + raise Exception("Asset directory info for '%s' is not a " + "dictionary." % name) + info.setdefault('processors', 'all -uglifyjs -cleancss') + info['path'] = os.path.join(root_dir, name) + + return mounts + + +def make_re(patterns): + re_patterns = [] + for pat in patterns: + if pat[0] == '/' and pat[-1] == '/' and len(pat) > 2: + re_patterns.append(pat[1:-1]) + else: + escaped_pat = ( + re.escape(pat) + .replace(r'\*', r'[^/\\]*') + .replace(r'\?', r'[^/\\]')) + re_patterns.append(escaped_pat) + return [re.compile(p) for p in re_patterns] + + +def re_matchany(filename, patterns, dirname=None): + if dirname and dirname != '.': + filename = os.path.join(dirname, filename) + + # skip patterns use a forward slash regardless of the platform. + filename = filename.replace('\\', '/') + for pattern in patterns: + if pattern.search(filename): + return True + return False +
--- a/piecrust/processing/records.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/processing/records.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,48 +1,33 @@ import os.path +import hashlib from piecrust.records import Record, TransitionalRecord class ProcessorPipelineRecord(Record): - RECORD_VERSION = 4 + RECORD_VERSION = 5 def __init__(self): super(ProcessorPipelineRecord, self).__init__() self.out_dir = None self.process_time = None + self.processed_count = 0 self.success = False - - def hasOverrideEntry(self, rel_path): - return self.findEntry(rel_path) is not None - - def findEntry(self, rel_path): - rel_path = rel_path.lower() - for entry in self.entries: - for out_path in entry.rel_outputs: - if out_path.lower() == rel_path: - return entry - return None - - def replaceEntry(self, new_entry): - for e in self.entries: - if (e.base_dir == new_entry.base_dir and - e.rel_input == new_entry.rel_input): - e.flags = new_entry.flags - e.rel_outputs = list(new_entry.rel_outputs) - e.errors = list(new_entry.errors) - break + self.timers = None FLAG_NONE = 0 FLAG_PREPARED = 2**0 FLAG_PROCESSED = 2**1 -FLAG_OVERRIDEN = 2**2 FLAG_BYPASSED_STRUCTURED_PROCESSING = 2**3 +def _get_transition_key(path): + return hashlib.md5(path.encode('utf8')).hexdigest() + + class ProcessorPipelineRecordEntry(object): - def __init__(self, base_dir, rel_input): - self.base_dir = base_dir - self.rel_input = rel_input + def __init__(self, path): + self.path = path self.flags = FLAG_NONE self.rel_outputs = [] @@ -50,10 +35,6 @@ self.errors = [] @property - def path(self): - return os.path.join(self.base_dir, self.rel_input) - - @property def was_prepared(self): return bool(self.flags & FLAG_PREPARED) @@ -73,10 +54,18 @@ ProcessorPipelineRecord, previous_path) def getTransitionKey(self, entry): - return entry.rel_input + return _get_transition_key(entry.path) - def getPreviousEntry(self, rel_path): - pair = self.transitions.get(rel_path) + def getCurrentEntry(self, path): + key = _get_transition_key(path) + pair = self.transitions.get(key) + if pair is not None: + return pair[1] + return None + + def getPreviousEntry(self, path): + key = _get_transition_key(path) + pair = self.transitions.get(key) if pair is not None: return pair[0] return None
--- a/piecrust/processing/sass.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/processing/sass.py Sat Jul 11 20:33:55 2015 -0700 @@ -26,12 +26,14 @@ def onPipelineStart(self, pipeline): super(SassProcessor, self).onPipelineStart(pipeline) - self._map_dir = os.path.join(pipeline.tmp_dir, 'sass') - if not os.path.isdir(self._map_dir): - os.makedirs(self._map_dir) + + if pipeline.is_first_worker: + self._map_dir = os.path.join(pipeline.tmp_dir, 'sass') + if not os.path.isdir(self._map_dir): + os.makedirs(self._map_dir) # Ignore include-only Sass files. - pipeline.addSkipPatterns(['_*.scss', '_*.sass']) + pipeline.addIgnorePatterns(['_*.scss', '_*.sass']) def getDependencies(self, path): if _is_include_only(path):
--- a/piecrust/processing/sitemap.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/processing/sitemap.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,7 +1,9 @@ import time import logging import yaml +from piecrust.data.iterators import PageIterator from piecrust.processing.base import SimpleFileProcessor +from piecrust.routing import create_route_metadata logger = logging.getLogger(__name__) @@ -9,8 +11,7 @@ SITEMAP_HEADER = \ """<?xml version="1.0" encoding="utf-8"?> -<urlset - xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> """ SITEMAP_FOOTER = "</urlset>\n" @@ -18,7 +19,7 @@ SITEURL_LOC = " <loc>%s</loc>\n" SITEURL_LASTMOD = " <lastmod>%s</lastmod>\n" SITEURL_CHANGEFREQ = " <changefreq>%s</changefreq>\n" -SITEURL_PRIORITY = " <priority>%f</priority>\n" +SITEURL_PRIORITY = " <priority>%0.1f</priority>\n" SITEURL_FOOTER = " </url>\n" @@ -58,21 +59,20 @@ if not source_names: return + cur_time = strftime_iso8601(time.time()) for name in source_names: logger.debug("Generating automatic sitemap entries for '%s'." % - name) + name) source = self.app.getSource(name) if source is None: raise Exception("No such source: %s" % name) - for page in source.getPages(): - route = self.app.getRoute(source.name, page.source_metadata) - uri = route.getUri(page.source_metadata, provider=page) + it = PageIterator(source) + for page in it: + uri = page['url'] + sm_cfg = page.get('sitemap') - t = page.datetime.timestamp() - sm_cfg = page.config.get('sitemap') - - args = {'url': uri, 'lastmod': strftime_iso8601(t)} + args = {'url': uri, 'lastmod': cur_time} if sm_cfg: args.update(sm_cfg)
--- a/piecrust/processing/tree.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/processing/tree.py Sat Jul 11 20:33:55 2015 -0700 @@ -79,7 +79,6 @@ self.processors = processors def build(self, path): - start_time = time.clock() tree_root = ProcessingTreeNode(path, list(self.processors)) loop_guard = 100 @@ -97,7 +96,7 @@ if proc.is_bypassing_structured_processing: if cur_node != tree_root: raise ProcessingTreeError("Only root processors can " - "bypass structured processing.") + "bypass structured processing.") break # Get the destination directory and output files. @@ -116,18 +115,14 @@ if proc.PROCESSOR_NAME != 'copy': walk_stack.append(out_node) - logger.debug(format_timed( - start_time, "Built processing tree for: %s" % path, - colored=False)) return tree_root class ProcessingTreeRunner(object): - def __init__(self, base_dir, tmp_dir, out_dir, lock=None): + def __init__(self, base_dir, tmp_dir, out_dir): self.base_dir = base_dir self.tmp_dir = tmp_dir self.out_dir = out_dir - self.lock = lock def processSubTree(self, tree_root): did_process = False @@ -155,8 +150,9 @@ proc = node.getProcessor() if proc.is_bypassing_structured_processing: try: - start_time = time.clock() - proc.process(full_path, self.out_dir) + start_time = time.perf_counter() + with proc.app.env.timerScope(proc.__class__.__name__): + proc.process(full_path, self.out_dir) print_node( node, format_timed( @@ -172,16 +168,15 @@ rel_out_dir = os.path.dirname(node.path) out_dir = os.path.join(base_out_dir, rel_out_dir) if not os.path.isdir(out_dir): - if self.lock: - with self.lock: - if not os.path.isdir(out_dir): - os.makedirs(out_dir, 0o755) - else: - os.makedirs(out_dir, 0o755) + try: + os.makedirs(out_dir, 0o755, exist_ok=True) + except OSError: + pass try: - start_time = time.clock() - proc_res = proc.process(full_path, out_dir) + start_time = time.perf_counter() + with proc.app.env.timerScope(proc.__class__.__name__): + proc_res = proc.process(full_path, out_dir) if proc_res is None: raise Exception("Processor '%s' didn't return a boolean " "result value." % proc) @@ -200,12 +195,12 @@ proc = node.getProcessor() if (proc.is_bypassing_structured_processing or - not proc.is_delegating_dependency_check): + not proc.is_delegating_dependency_check): # This processor wants to handle things on its own... node.setState(STATE_DIRTY, False) return - start_time = time.clock() + start_time = time.perf_counter() # Get paths and modification times for the input path and # all dependencies (if any).
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/processing/worker.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,184 @@ +import re +import os.path +import time +import logging +from piecrust.app import PieCrust +from piecrust.processing.base import PipelineContext +from piecrust.processing.records import ( + FLAG_NONE, FLAG_PREPARED, FLAG_PROCESSED, + FLAG_BYPASSED_STRUCTURED_PROCESSING) +from piecrust.processing.tree import ( + ProcessingTreeBuilder, ProcessingTreeRunner, + ProcessingTreeError, ProcessorError, + get_node_name_tree, print_node, + STATE_DIRTY) +from piecrust.workerpool import IWorker + + +logger = logging.getLogger(__name__) + + +split_processor_names_re = re.compile(r'[ ,]+') +re_ansicolors = re.compile('\033\\[\d+m') + + +class ProcessingWorkerContext(object): + def __init__(self, root_dir, out_dir, tmp_dir, + force=False, debug=False): + self.root_dir = root_dir + self.out_dir = out_dir + self.tmp_dir = tmp_dir + self.force = force + self.debug = debug + self.is_profiling = False + self.enabled_processors = None + self.additional_processors = None + + +class ProcessingWorkerJob(object): + def __init__(self, base_dir, mount_info, path, *, force=False): + self.base_dir = base_dir + self.mount_info = mount_info + self.path = path + self.force = force + + +class ProcessingWorkerResult(object): + def __init__(self, path): + self.path = path + self.flags = FLAG_NONE + self.proc_tree = None + self.rel_outputs = None + self.errors = None + + +class ProcessingWorker(IWorker): + def __init__(self, ctx): + self.ctx = ctx + self.work_start_time = time.perf_counter() + + def initialize(self): + # Create the app local to this worker. + app = PieCrust(self.ctx.root_dir, debug=self.ctx.debug) + app.env.registerTimer("PipelineWorker_%d_Total" % self.wid) + app.env.registerTimer("PipelineWorkerInit") + app.env.registerTimer("JobReceive") + app.env.registerTimer('BuildProcessingTree') + app.env.registerTimer('RunProcessingTree') + self.app = app + + processors = app.plugin_loader.getProcessors() + if self.ctx.enabled_processors: + logger.debug("Filtering processors to: %s" % + self.ctx.enabled_processors) + processors = get_filtered_processors(processors, + self.ctx.enabled_processors) + if self.ctx.additional_processors: + logger.debug("Adding %s additional processors." % + len(self.ctx.additional_processors)) + for proc in self.ctx.additional_processors: + app.env.registerTimer(proc.__class__.__name__) + proc.initialize(app) + processors.append(proc) + self.processors = processors + + # Invoke pre-processors. + pipeline_ctx = PipelineContext(self.wid, self.app, self.ctx.out_dir, + self.ctx.tmp_dir, self.ctx.force) + for proc in processors: + proc.onPipelineStart(pipeline_ctx) + + # Sort our processors again in case the pre-process step involved + # patching the processors with some new ones. + processors.sort(key=lambda p: p.priority) + + app.env.stepTimerSince("PipelineWorkerInit", self.work_start_time) + + def process(self, job): + result = ProcessingWorkerResult(job.path) + + processors = get_filtered_processors( + self.processors, job.mount_info['processors']) + + # Build the processing tree for this job. + rel_path = os.path.relpath(job.path, job.base_dir) + try: + with self.app.env.timerScope('BuildProcessingTree'): + builder = ProcessingTreeBuilder(processors) + tree_root = builder.build(rel_path) + result.flags |= FLAG_PREPARED + except ProcessingTreeError as ex: + result.errors = _get_errors(ex) + return result + + # Prepare and run the tree. + print_node(tree_root, recursive=True) + leaves = tree_root.getLeaves() + result.rel_outputs = [l.path for l in leaves] + result.proc_tree = get_node_name_tree(tree_root) + if tree_root.getProcessor().is_bypassing_structured_processing: + result.flags |= FLAG_BYPASSED_STRUCTURED_PROCESSING + + if job.force: + tree_root.setState(STATE_DIRTY, True) + + try: + with self.app.env.timerScope('RunProcessingTree'): + runner = ProcessingTreeRunner( + job.base_dir, self.ctx.tmp_dir, self.ctx.out_dir) + if runner.processSubTree(tree_root): + result.flags |= FLAG_PROCESSED + except ProcessingTreeError as ex: + if isinstance(ex, ProcessorError): + ex = ex.__cause__ + # Need to strip out colored errors from external processes. + result.errors = _get_errors(ex, strip_colors=True) + + return result + + def getReport(self): + # Invoke post-processors. + pipeline_ctx = PipelineContext(self.wid, self.app, self.ctx.out_dir, + self.ctx.tmp_dir, self.ctx.force) + for proc in self.processors: + proc.onPipelineEnd(pipeline_ctx) + + self.app.env.stepTimerSince("PipelineWorker_%d_Total" % self.wid, + self.work_start_time) + return { + 'type': 'timers', + 'data': self.app.env._timers} + + +def get_filtered_processors(processors, authorized_names): + if not authorized_names or authorized_names == 'all': + return processors + + if isinstance(authorized_names, str): + authorized_names = split_processor_names_re.split(authorized_names) + + procs = [] + has_star = 'all' in authorized_names + for p in processors: + for name in authorized_names: + if name == p.PROCESSOR_NAME: + procs.append(p) + break + if name == ('-%s' % p.PROCESSOR_NAME): + break + else: + if has_star: + procs.append(p) + return procs + + +def _get_errors(ex, strip_colors=False): + errors = [] + while ex is not None: + msg = str(ex) + if strip_colors: + msg = re_ansicolors.sub('', msg) + errors.append(msg) + ex = ex.__cause__ + return errors +
--- a/piecrust/rendering.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/rendering.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,9 +1,10 @@ import re import os.path +import copy import logging from werkzeug.utils import cached_property -from piecrust.data.builder import (DataBuildingContext, build_page_data, - build_layout_data) +from piecrust.data.builder import ( + DataBuildingContext, build_page_data, build_layout_data) from piecrust.data.filters import ( PaginationFilter, HasFilterClause, IsFilterClause, AndBooleanClause, page_value_accessor) @@ -33,13 +34,24 @@ self.route_metadata = route_metadata def getUri(self, sub_num=1): - return self.route.getUri(self.route_metadata, provider=self.page, - sub_num=sub_num) + return self.route.getUri(self.route_metadata, sub_num=sub_num) def __getattr__(self, name): return getattr(self.page, name) +class RenderedSegments(object): + def __init__(self, segments, render_pass_info): + self.segments = segments + self.render_pass_info = render_pass_info + + +class RenderedLayout(object): + def __init__(self, content, render_pass_info): + self.content = content + self.render_pass_info = render_pass_info + + class RenderedPage(object): def __init__(self, page, uri, num=1): self.page = page @@ -47,11 +59,15 @@ self.num = num self.data = None self.content = None + self.render_info = None @property def app(self): return self.page.app + def copyRenderInfo(self): + return copy.deepcopy(self.render_info) + PASS_NONE = 0 PASS_FORMATTING = 1 @@ -65,6 +81,41 @@ def __init__(self): self.used_source_names = set() self.used_taxonomy_terms = set() + self.used_pagination = False + self.pagination_has_more = False + self.used_assets = False + + def merge(self, other): + self.used_source_names |= other.used_source_names + self.used_taxonomy_terms |= other.used_taxonomy_terms + self.used_pagination = self.used_pagination or other.used_pagination + self.pagination_has_more = (self.pagination_has_more or + other.pagination_has_more) + self.used_assets = self.used_assets or other.used_assets + + def _toJson(self): + data = { + 'used_source_names': list(self.used_source_names), + 'used_taxonomy_terms': list(self.used_taxonomy_terms), + 'used_pagination': self.used_pagination, + 'pagination_has_more': self.pagination_has_more, + 'used_assets': self.used_assets} + return data + + @staticmethod + def _fromJson(data): + assert data is not None + rpi = RenderPassInfo() + rpi.used_source_names = set(data['used_source_names']) + for i in data['used_taxonomy_terms']: + terms = i[2] + if isinstance(terms, list): + terms = tuple(terms) + rpi.used_taxonomy_terms.add((i[0], i[1], terms)) + rpi.used_pagination = data['used_pagination'] + rpi.pagination_has_more = data['pagination_has_more'] + rpi.used_assets = data['used_assets'] + return rpi class PageRenderingContext(object): @@ -78,8 +129,6 @@ self._current_pass = PASS_NONE self.render_passes = {} - self.used_pagination = None - self.used_assets = None @property def app(self): @@ -104,15 +153,18 @@ def setPagination(self, paginator): self._raiseIfNoCurrentPass() - if self.used_pagination is not None: + pass_info = self.current_pass_info + if pass_info.used_pagination: raise Exception("Pagination has already been used.") - self.used_pagination = paginator + assert paginator.is_loaded + pass_info.used_pagination = True + pass_info.pagination_has_more = paginator.has_more self.addUsedSource(paginator._source) def addUsedSource(self, source): self._raiseIfNoCurrentPass() if isinstance(source, PageSource): - pass_info = self.render_passes[self._current_pass] + pass_info = self.current_pass_info pass_info.used_source_names.add(source.name) def setTaxonomyFilter(self, taxonomy, term_value): @@ -144,44 +196,50 @@ eis = ctx.app.env.exec_info_stack eis.pushPage(ctx.page, ctx) try: - page = ctx.page - # Build the data for both segment and layout rendering. - data_ctx = DataBuildingContext(page, page_num=ctx.page_num) - data_ctx.pagination_source = ctx.pagination_source - data_ctx.pagination_filter = ctx.pagination_filter - page_data = build_page_data(data_ctx) - if ctx.custom_data: - page_data.update(ctx.custom_data) + page_data = _build_render_data(ctx) # Render content segments. ctx.setCurrentPass(PASS_FORMATTING) repo = ctx.app.env.rendered_segments_repository + save_to_fs = True + if ctx.app.env.fs_cache_only_for_main_page and not eis.is_main_page: + save_to_fs = False if repo and not ctx.force_render: - cache_key = ctx.uri - page_time = page.path_mtime - contents = repo.get( - cache_key, - lambda: _do_render_page_segments(page, page_data), - fs_cache_time=page_time) + render_result = repo.get( + ctx.uri, + lambda: _do_render_page_segments(ctx.page, page_data), + fs_cache_time=ctx.page.path_mtime, + save_to_fs=save_to_fs) else: - contents = _do_render_page_segments(page, page_data) + render_result = _do_render_page_segments(ctx.page, page_data) + if repo: + repo.put(ctx.uri, render_result, save_to_fs) # Render layout. + page = ctx.page ctx.setCurrentPass(PASS_RENDERING) layout_name = page.config.get('layout') if layout_name is None: layout_name = page.source.config.get('default_layout', 'default') null_names = ['', 'none', 'nil'] if layout_name not in null_names: - build_layout_data(page, page_data, contents) - output = render_layout(layout_name, page, page_data) + build_layout_data(page, page_data, render_result['segments']) + layout_result = _do_render_layout(layout_name, page, page_data) else: - output = contents['content'] + layout_result = { + 'content': render_result['segments']['content'], + 'pass_info': None} rp = RenderedPage(page, ctx.uri, ctx.page_num) rp.data = page_data - rp.content = output + rp.content = layout_result['content'] + rp.render_info = { + PASS_FORMATTING: RenderPassInfo._fromJson( + render_result['pass_info'])} + if layout_result['pass_info'] is not None: + rp.render_info[PASS_RENDERING] = RenderPassInfo._fromJson( + layout_result['pass_info']) return rp finally: ctx.setCurrentPass(PASS_NONE) @@ -189,69 +247,100 @@ def render_page_segments(ctx): - repo = ctx.app.env.rendered_segments_repository - if repo: - cache_key = ctx.uri - return repo.get( - cache_key, - lambda: _do_render_page_segments_from_ctx(ctx), - fs_cache_time=ctx.page.path_mtime) - - return _do_render_page_segments_from_ctx(ctx) - - -def _do_render_page_segments_from_ctx(ctx): eis = ctx.app.env.exec_info_stack eis.pushPage(ctx.page, ctx) - ctx.setCurrentPass(PASS_FORMATTING) try: - data_ctx = DataBuildingContext(ctx.page, page_num=ctx.page_num) - page_data = build_page_data(data_ctx) - return _do_render_page_segments(ctx.page, page_data) + ctx.setCurrentPass(PASS_FORMATTING) + repo = ctx.app.env.rendered_segments_repository + save_to_fs = True + if ctx.app.env.fs_cache_only_for_main_page and not eis.is_main_page: + save_to_fs = False + if repo and not ctx.force_render: + render_result = repo.get( + ctx.uri, + lambda: _do_render_page_segments_from_ctx(ctx), + fs_cache_time=ctx.page.path_mtime, + save_to_fs=save_to_fs) + else: + render_result = _do_render_page_segments_from_ctx(ctx) + if repo: + repo.put(ctx.uri, render_result, save_to_fs) finally: ctx.setCurrentPass(PASS_NONE) eis.popPage() + rs = RenderedSegments( + render_result['segments'], + RenderPassInfo._fromJson(render_result['pass_info'])) + return rs + + +def _build_render_data(ctx): + with ctx.app.env.timerScope("PageDataBuild"): + data_ctx = DataBuildingContext(ctx.page, page_num=ctx.page_num) + data_ctx.pagination_source = ctx.pagination_source + data_ctx.pagination_filter = ctx.pagination_filter + page_data = build_page_data(data_ctx) + if ctx.custom_data: + page_data._appendMapping(ctx.custom_data) + return page_data + + +def _do_render_page_segments_from_ctx(ctx): + page_data = _build_render_data(ctx) + return _do_render_page_segments(ctx.page, page_data) + def _do_render_page_segments(page, page_data): app = page.app + + cpi = app.env.exec_info_stack.current_page_info + assert cpi is not None + assert cpi.page == page + engine_name = page.config.get('template_engine') format_name = page.config.get('format') engine = get_template_engine(app, engine_name) - formatted_content = {} + formatted_segments = {} for seg_name, seg in page.raw_content.items(): seg_text = '' for seg_part in seg.parts: part_format = seg_part.fmt or format_name try: - part_text = engine.renderString( - seg_part.content, page_data, - filename=page.path) + with app.env.timerScope(engine.__class__.__name__): + part_text = engine.renderSegmentPart( + page.path, seg_part, page_data) except TemplatingError as err: err.lineno += seg_part.line raise err part_text = format_text(app, part_format, part_text) seg_text += part_text - formatted_content[seg_name] = seg_text + formatted_segments[seg_name] = seg_text if seg_name == 'content': m = content_abstract_re.search(seg_text) if m: offset = m.start() content_abstract = seg_text[:offset] - formatted_content['content.abstract'] = content_abstract + formatted_segments['content.abstract'] = content_abstract - return formatted_content + pass_info = cpi.render_ctx.render_passes.get(PASS_FORMATTING) + res = { + 'segments': formatted_segments, + 'pass_info': pass_info._toJson()} + return res -def render_layout(layout_name, page, layout_data): +def _do_render_layout(layout_name, page, layout_data): + cpi = page.app.env.exec_info_stack.current_page_info + assert cpi is not None + assert cpi.page == page + names = layout_name.split(',') - default_template_engine = get_template_engine(page.app, None) - default_exts = ['.' + e.lstrip('.') - for e in default_template_engine.EXTENSIONS] + default_exts = page.app.env.default_layout_extensions full_names = [] for name in names: if '.' not in name: @@ -270,7 +359,10 @@ msg = "Can't find template for page: %s\n" % page.path msg += "Looked for: %s" % ', '.join(full_names) raise Exception(msg) from ex - return output + + pass_info = cpi.render_ctx.render_passes.get(PASS_RENDERING) + res = {'content': output, 'pass_info': pass_info._toJson()} + return res def get_template_engine(app, engine_name): @@ -290,8 +382,11 @@ format_count = 0 format_name = format_name or app.config.get('site/default_format') for fmt in app.plugin_loader.getFormatters(): + if not fmt.enabled: + continue if fmt.FORMAT_NAMES is None or format_name in fmt.FORMAT_NAMES: - txt = fmt.render(format_name, txt) + with app.env.timerScope(fmt.__class__.__name__): + txt = fmt.render(format_name, txt) format_count += 1 if fmt.OUTPUT_FORMAT is not None: format_name = fmt.OUTPUT_FORMAT
--- a/piecrust/routing.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/routing.py Sat Jul 11 20:33:55 2015 -0700 @@ -15,6 +15,18 @@ ugly_url_cleaner = re.compile(r'\.html$') +def create_route_metadata(page): + route_metadata = copy.deepcopy(page.source_metadata) + route_metadata.update(page.getRouteMetadata()) + + # TODO: fix this hard-coded shit + for key in ['year', 'month', 'day']: + if key in route_metadata and isinstance(route_metadata[key], str): + route_metadata[key] = int(route_metadata[key]) + + return route_metadata + + class IRouteMetadataProvider(object): def getRouteMetadata(self): raise NotImplementedError() @@ -120,18 +132,14 @@ for k in missing_keys: route_metadata[k] = '' - return route_metadata - - def getUri(self, route_metadata, *, sub_num=1, provider=None): - route_metadata = copy.deepcopy(route_metadata) - if provider: - route_metadata.update(provider.getRouteMetadata()) - - #TODO: fix this hard-coded shit + # TODO: fix this hard-coded shit for key in ['year', 'month', 'day']: if key in route_metadata and isinstance(route_metadata[key], str): route_metadata[key] = int(route_metadata[key]) + return route_metadata + + def getUri(self, route_metadata, *, sub_num=1): uri = self.uri_format % route_metadata suffix = None if sub_num > 1: @@ -280,6 +288,9 @@ len(args))) metadata = {} for arg_name, arg_val in zip(self.template_func_args, args): + #TODO: fix this hard-coded shit. + if arg_name in ['year', 'month', 'day']: + arg_val = int(arg_val) metadata[arg_name] = arg_val return self.getUri(metadata)
--- a/piecrust/serving/server.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/serving/server.py Sat Jul 11 20:33:55 2015 -0700 @@ -12,7 +12,7 @@ from werkzeug.wrappers import Request, Response from werkzeug.wsgi import ClosingIterator, wrap_file from jinja2 import FileSystemLoader, Environment -from piecrust import CACHE_DIR +from piecrust import CACHE_DIR, RESOURCES_DIR from piecrust.app import PieCrust from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page from piecrust.sources.base import MODE_PARSING @@ -81,7 +81,7 @@ # use process forking and we end up going here twice. We only want # to start the pipeline loop in the inner process most of the # time so we let the implementation tell us if this is OK. - from piecrust.processing.base import ProcessorPipeline + from piecrust.processing.pipeline import ProcessorPipeline from piecrust.serving.procloop import ProcessingLoop pipeline = ProcessorPipeline(app, self._out_dir) self._proc_loop = ProcessingLoop(pipeline) @@ -96,6 +96,8 @@ return self._try_run_request(environ, start_response) except Exception as ex: if self.debug: + if isinstance(ex, HTTPException): + return ex raise return self._handle_error(ex, environ, start_response) @@ -158,9 +160,7 @@ static_mount = '/__piecrust_static/' if request.path.startswith(static_mount): rel_req_path = request.path[len(static_mount):] - mount = os.path.join( - os.path.dirname(__file__), - 'resources', 'server') + mount = os.path.join(RESOURCES_DIR, 'server') full_path = os.path.join(mount, rel_req_path) try: response = self._make_wrapped_file_response( @@ -173,7 +173,7 @@ if request.path.startswith(debug_mount): rel_req_path = request.path[len(debug_mount):] if rel_req_path == 'pipeline_status': - from piecrust.server.procloop import ( + from piecrust.serving.procloop import ( PipelineStatusServerSideEventProducer) provider = PipelineStatusServerSideEventProducer( self._proc_loop.status_queue) @@ -321,7 +321,7 @@ if route_terms is None: return None - tax_page_ref = taxonomy.getPageRef(source.name) + tax_page_ref = taxonomy.getPageRef(source) factory = tax_page_ref.getFactory() tax_terms = route.unslugifyTaxonomyTerm(route_terms) route_metadata[taxonomy.term_name] = tax_terms @@ -459,8 +459,7 @@ class ErrorMessageLoader(FileSystemLoader): def __init__(self): - base_dir = os.path.join(os.path.dirname(__file__), 'resources', - 'messages') + base_dir = os.path.join(RESOURCES_DIR, 'messages') super(ErrorMessageLoader, self).__init__(base_dir) def get_source(self, env, template):
--- a/piecrust/sources/array.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/sources/array.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,5 +1,6 @@ from piecrust.sources.base import PageSource from piecrust.sources.mixins import SimplePaginationSourceMixin +from piecrust.sources.pageref import PageRef class CachedPageFactory(object): @@ -30,7 +31,7 @@ class ArraySource(PageSource, SimplePaginationSourceMixin): def __init__(self, app, inner_source, name='array', config=None): - super(ArraySource, self).__init__(app, name, config or {}) + super(ArraySource, self).__init__(app, name, config) self.inner_source = inner_source @property @@ -41,3 +42,6 @@ for p in self.inner_source: yield CachedPageFactory(p) + def getTaxonomyPageRef(self, tax_name): + return None +
--- a/piecrust/sources/base.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/sources/base.py Sat Jul 11 20:33:55 2015 -0700 @@ -20,9 +20,8 @@ def build_pages(app, factories): - with app.env.page_repository.startBatchGet(): - for f in factories: - yield f.buildPage() + for f in factories: + yield f.buildPage() class InvalidFileSystemEndpointError(Exception): @@ -59,9 +58,6 @@ def _doBuildPage(self): logger.debug("Building page: %s" % self.path) page = Page(self.source, copy.deepcopy(self.metadata), self.rel_path) - # Load it right away, especially when using the page repository, - # because we'll be inside a critical scope. - page._load() return page @@ -118,7 +114,7 @@ def findPageFactory(self, metadata, mode): raise NotImplementedError() - def buildDataProvider(self, page, user_data): + def buildDataProvider(self, page, override): if self._provider_type is None: cls = next((pt for pt in self.app.plugin_loader.getDataProviders() if pt.PROVIDER_NAME == self.data_type), @@ -128,7 +124,7 @@ "Unknown data provider type: %s" % self.data_type) self._provider_type = cls - return self._provider_type(self, page, user_data) + return self._provider_type(self, page, override) def getTaxonomyPageRef(self, tax_name): tax_pages = self.config.get('taxonomy_pages')
--- a/piecrust/sources/mixins.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/sources/mixins.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,11 +1,11 @@ import os import os.path import logging -from piecrust.data.base import PaginationData from piecrust.data.filters import PaginationFilter, page_value_accessor +from piecrust.data.paginationdata import PaginationData from piecrust.sources.base import PageFactory from piecrust.sources.interfaces import IPaginationSource, IListableSource -from piecrust.sources.pageref import PageNotFoundError +from piecrust.sources.pageref import PageRef logger = logging.getLogger(__name__) @@ -41,13 +41,15 @@ if self._taxonomy_pages is not None: return + app = self.source.app self._taxonomy_pages = set() - for tax in self.source.app.taxonomies: - page_ref = tax.getPageRef(self.source.name) - try: - self._taxonomy_pages.add(page_ref.rel_path) - except PageNotFoundError: - pass + for src in app.sources: + for tax in app.taxonomies: + ref_spec = src.getTaxonomyPageRef(tax.name) + page_ref = PageRef(app, ref_spec) + for sn, rp in page_ref.possible_split_ref_specs: + if sn == self.source.name: + self._taxonomy_pages.add(rp) class DateSortIterator(object):
--- a/piecrust/sources/pageref.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/sources/pageref.py Sat Jul 11 20:33:55 2015 -0700 @@ -42,7 +42,6 @@ @property def source_name(self): - self._checkHits() return self._first_valid_hit.source_name @property @@ -51,23 +50,25 @@ @property def rel_path(self): - self._checkHits() return self._first_valid_hit.rel_path @property def path(self): - self._checkHits() return self._first_valid_hit.path @property def metadata(self): - self._checkHits() return self._first_valid_hit.metadata @property - def possible_rel_paths(self): + def possible_ref_specs(self): self._load() - return [h.rel_path for h in self._hits] + return ['%s:%s' % (h.source_name, h.rel_path) for h in self._hits] + + @property + def possible_split_ref_specs(self): + self._load() + return [(h.source_name, h.rel_path) for h in self._hits] @property def possible_paths(self): @@ -80,17 +81,23 @@ @property def _first_valid_hit(self): + self._checkHits() return self._hits[self._first_valid_hit_index] def _load(self): if self._hits is not None: return + self._hits = [] + + if self._page_ref is None: + self._first_valid_hit_index = self._INDEX_NOT_FOUND + return + it = list(page_ref_pattern.finditer(self._page_ref)) if len(it) == 0: raise Exception("Invalid page ref: %s" % self._page_ref) - self._hits = [] for m in it: source_name = m.group('src') source = self.app.getSource(source_name) @@ -111,15 +118,17 @@ def _checkHits(self): if self._first_valid_hit_index >= 0: return + + if self._first_valid_hit_index == self._INDEX_NEEDS_LOADING: + self._load() + self._first_valid_hit_index = self._INDEX_NOT_FOUND + for i, hit in enumerate(self._hits): + if os.path.isfile(hit.path): + self._first_valid_hit_index = i + break + if self._first_valid_hit_index == self._INDEX_NOT_FOUND: raise PageNotFoundError( "No valid paths were found for page reference: %s" % self._page_ref) - self._load() - self._first_valid_hit_index = self._INDEX_NOT_FOUND - for i, hit in enumerate(self._hits): - if os.path.isfile(hit.path): - self._first_valid_hit_index = i - break -
--- a/piecrust/taxonomies.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/taxonomies.py Sat Jul 11 20:33:55 2015 -0700 @@ -16,21 +16,19 @@ return self.name return self.term_name - def resolvePagePath(self, source_name): - pr = self.getPageRef(source_name) + def resolvePagePath(self, source): + pr = self.getPageRef(source) try: return pr.path except PageNotFoundError: return None - def getPageRef(self, source_name): - if source_name in self._source_page_refs: - return self._source_page_refs[source_name] + def getPageRef(self, source): + if source.name in self._source_page_refs: + return self._source_page_refs[source.name] - source = self.app.getSource(source_name) - ref_path = (source.getTaxonomyPageRef(self.name) or - '%s:%s' % (source_name, self.page_ref)) + ref_path = source.getTaxonomyPageRef(self.name) page_ref = PageRef(self.app, ref_path) - self._source_page_refs[source_name] = page_ref + self._source_page_refs[source.name] = page_ref return page_ref
--- a/piecrust/templating/base.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/templating/base.py Sat Jul 11 20:33:55 2015 -0700 @@ -27,7 +27,7 @@ def initialize(self, app): self.app = app - def renderString(self, txt, data, filename=None, line_offset=0): + def renderSegmentPart(self, path, seg_part, data): raise NotImplementedError() def renderFile(self, paths, data):
--- a/piecrust/templating/jinjaengine.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/templating/jinjaengine.py Sat Jul 11 20:33:55 2015 -0700 @@ -33,13 +33,19 @@ def __init__(self): self.env = None - def renderString(self, txt, data, filename=None): + def renderSegmentPart(self, path, seg_part, data): self._ensureLoaded() + if not _string_needs_render(seg_part.content): + return seg_part.content + + part_path = _make_segment_part_path(path, seg_part.offset) + self.env.loader.segment_parts_cache[part_path] = ( + path, seg_part.content) try: - tpl = self.env.from_string(txt) + tpl = self.env.get_template(part_path) except TemplateSyntaxError as tse: - raise self._getTemplatingError(tse, filename=filename) + raise self._getTemplatingError(tse, filename=path) except TemplateNotFound: raise TemplateNotFoundError() @@ -87,8 +93,8 @@ autoescape = True logger.debug("Creating Jinja environment with folders: %s" % - self.app.templates_dirs) - loader = FileSystemLoader(self.app.templates_dirs) + self.app.templates_dirs) + loader = PieCrustLoader(self.app.templates_dirs) extensions = [ PieCrustHighlightExtension, PieCrustCacheExtension, @@ -102,6 +108,42 @@ extensions=extensions) +def _string_needs_render(txt): + index = txt.find('{') + while index >= 0: + ch = txt[index + 1] + if ch == '{' or ch == '%': + return True + index = txt.find('{', index + 1) + return False + + +def _make_segment_part_path(path, start): + return '$part=%s:%d' % (path, start) + + +class PieCrustLoader(FileSystemLoader): + def __init__(self, searchpath, encoding='utf-8'): + super(PieCrustLoader, self).__init__(searchpath, encoding) + self.segment_parts_cache = {} + + def get_source(self, environment, template): + if template.startswith('$part='): + filename, seg_part = self.segment_parts_cache[template] + + mtime = os.path.getmtime(filename) + + def uptodate(): + try: + return os.path.getmtime(filename) == mtime + except OSError: + return False + + return seg_part, filename, uptodate + + return super(PieCrustLoader, self).get_source(environment, template) + + class PieCrustEnvironment(Environment): def __init__(self, app, *args, **kwargs): self.app = app @@ -301,7 +343,8 @@ args = [parser.parse_expression()] # Extract optional arguments. - kwarg_names = {'line_numbers': 0, 'use_classes': 0, 'class': 1, 'id': 1} + kwarg_names = {'line_numbers': 0, 'use_classes': 0, 'class': 1, + 'id': 1} kwargs = {} while not parser.stream.current.test('block_end'): name = parser.stream.expect('name') @@ -321,7 +364,7 @@ [], [], body).set_lineno(lineno) def _highlight(self, lang, line_numbers=False, use_classes=False, - css_class=None, css_id=None, caller=None): + css_class=None, css_id=None, caller=None): # Try to be mostly compatible with Jinja2-highlight's settings. body = caller() @@ -375,12 +418,12 @@ # now we parse the body of the cache block up to `endpccache` and # drop the needle (which would always be `endpccache` in that case) body = parser.parse_statements(['name:endpccache', 'name:endcache'], - drop_needle=True) + drop_needle=True) # now return a `CallBlock` node that calls our _cache_support # helper method on this extension. return CallBlock(self.call_method('_cache_support', args), - [], [], body).set_lineno(lineno) + [], [], body).set_lineno(lineno) def _cache_support(self, name, caller): key = self.environment.piecrust_cache_prefix + name
--- a/piecrust/templating/pystacheengine.py Thu Jun 25 05:51:47 2015 +1000 +++ b/piecrust/templating/pystacheengine.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,5 +1,7 @@ import logging +import collections.abc import pystache +import pystache.common from piecrust.templating.base import ( TemplateEngine, TemplateNotFoundError, TemplatingError) @@ -14,14 +16,14 @@ def __init__(self): self.renderer = None - def renderString(self, txt, data, filename=None): + def renderSegmentPart(self, path, seg_part, data): self._ensureLoaded() try: - return self.renderer.render(txt, data) - except pystache.TemplateNotFoundError as ex: + return self.renderer.render(seg_part.content, data) + except pystache.common.TemplateNotFoundError as ex: raise TemplateNotFoundError() from ex - except pystache.PystacheError as ex: - raise TemplatingError(str(ex), filename) from ex + except pystache.common.PystacheError as ex: + raise TemplatingError(str(ex), path) from ex def renderFile(self, paths, data): self._ensureLoaded() @@ -45,13 +47,35 @@ try: return self.renderer.render(tpl, data) - except pystache.PystacheError as ex: + except pystache.common.PystacheError as ex: raise TemplatingError(str(ex)) from ex def _ensureLoaded(self): if self.renderer: return - self.renderer = pystache.Renderer( + self.renderer = _WorkaroundRenderer( search_dirs=self.app.templates_dirs) + +_knowns = ['PieCrustData', 'LazyPageConfigData', 'Paginator', 'Assetor', + 'PageLinkerData'] + + +class _WorkaroundRenderer(pystache.Renderer): + def _make_resolve_context(self): + mrc = super(_WorkaroundRenderer, self)._make_resolve_context() + + def _workaround(stack, name): + # Pystache will treat anything that's not a string or a dict as + # a list. This is just plain wrong, but it will take a while before + # the project can get patches on Pypi. + res = mrc(stack, name) + if res is not None and ( + res.__class__.__name__ in _knowns or + isinstance(res, collections.abc.Mapping)): + res = [res] + return res + + return _workaround +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/piecrust/workerpool.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,349 @@ +import os +import sys +import zlib +import logging +import itertools +import threading +import multiprocessing +from piecrust.fastpickle import pickle, unpickle + + +logger = logging.getLogger(__name__) + + +class IWorker(object): + def initialize(self): + raise NotImplementedError() + + def process(self, job): + raise NotImplementedError() + + def getReport(self): + return None + + +TASK_JOB = 0 +TASK_BATCH = 1 +TASK_END = 2 + + +def worker_func(params): + if params.is_profiling: + try: + import cProfile as profile + except ImportError: + import profile + + params.is_profiling = False + name = params.worker_class.__name__ + profile.runctx('_real_worker_func(params)', + globals(), locals(), + filename='%s-%d.prof' % (name, params.wid)) + else: + _real_worker_func(params) + + +def _real_worker_func(params): + wid = params.wid + logger.debug("Worker %d initializing..." % wid) + + params.inqueue._writer.close() + params.outqueue._reader.close() + + w = params.worker_class(*params.initargs) + w.wid = wid + try: + w.initialize() + except Exception as ex: + logger.error("Working failed to initialize:") + logger.exception(ex) + params.outqueue.put(None) + return + + get = params.inqueue.get + put = params.outqueue.put + + completed = 0 + while True: + try: + task = get() + except (EOFError, OSError): + logger.debug("Worker %d encountered connection problem." % wid) + break + + task_type, task_data = task + if task_type == TASK_END: + logger.debug("Worker %d got end task, exiting." % wid) + try: + rep = (task_type, True, wid, (wid, w.getReport())) + except Exception as e: + if params.wrap_exception: + e = multiprocessing.ExceptionWithTraceback( + e, e.__traceback__) + rep = (task_type, False, wid, (wid, e)) + put(rep) + break + + if task_type == TASK_JOB: + task_data = (task_data,) + + for t in task_data: + try: + res = (TASK_JOB, True, wid, w.process(t)) + except Exception as e: + if params.wrap_exception: + e = multiprocessing.ExceptionWithTraceback( + e, e.__traceback__) + res = (TASK_JOB, False, wid, e) + put(res) + + completed += 1 + + logger.debug("Worker %d completed %d tasks." % (wid, completed)) + + +class _WorkerParams(object): + def __init__(self, wid, inqueue, outqueue, worker_class, initargs=(), + wrap_exception=False, is_profiling=False): + self.wid = wid + self.inqueue = inqueue + self.outqueue = outqueue + self.worker_class = worker_class + self.initargs = initargs + self.wrap_exception = wrap_exception + self.is_profiling = is_profiling + + +class WorkerPool(object): + def __init__(self, worker_class, initargs=(), + worker_count=None, batch_size=None, + wrap_exception=False): + worker_count = worker_count or os.cpu_count() or 1 + + use_fastqueue = True + if use_fastqueue: + self._task_queue = FastQueue() + self._result_queue = FastQueue() + self._quick_put = self._task_queue.put + self._quick_get = self._result_queue.get + else: + self._task_queue = multiprocessing.SimpleQueue() + self._result_queue = multiprocessing.SimpleQueue() + self._quick_put = self._task_queue._writer.send + self._quick_get = self._result_queue._reader.recv + + self._batch_size = batch_size + self._callback = None + self._error_callback = None + self._listener = None + + main_module = sys.modules['__main__'] + is_profiling = os.path.basename(main_module.__file__) in [ + 'profile.py', 'cProfile.py'] + + self._pool = [] + for i in range(worker_count): + worker_params = _WorkerParams( + i, self._task_queue, self._result_queue, + worker_class, initargs, + wrap_exception=wrap_exception, + is_profiling=is_profiling) + w = multiprocessing.Process(target=worker_func, + args=(worker_params,)) + w.name = w.name.replace('Process', 'PoolWorker') + w.daemon = True + w.start() + self._pool.append(w) + + self._result_handler = threading.Thread( + target=WorkerPool._handleResults, + args=(self,)) + self._result_handler.daemon = True + self._result_handler.start() + + self._closed = False + + def setHandler(self, callback=None, error_callback=None): + self._callback = callback + self._error_callback = error_callback + + def queueJobs(self, jobs, handler=None, chunk_size=None): + if self._closed: + raise Exception("This worker pool has been closed.") + if self._listener is not None: + raise Exception("A previous job queue has not finished yet.") + + if any([not p.is_alive() for p in self._pool]): + raise Exception("Some workers have prematurely exited.") + + if handler is not None: + self.setHandler(handler) + + if not hasattr(jobs, '__len__'): + jobs = list(jobs) + job_count = len(jobs) + + res = AsyncResult(self, job_count) + if res._count == 0: + res._event.set() + return res + + self._listener = res + + if chunk_size is None: + chunk_size = self._batch_size + if chunk_size is None: + chunk_size = max(1, job_count // 50) + logger.debug("Using chunk size of %d" % chunk_size) + + if chunk_size is None or chunk_size == 1: + for job in jobs: + self._quick_put((TASK_JOB, job)) + else: + it = iter(jobs) + while True: + batch = tuple([i for i in itertools.islice(it, chunk_size)]) + if not batch: + break + self._quick_put((TASK_BATCH, batch)) + + return res + + def close(self): + if self._listener is not None: + raise Exception("A previous job queue has not finished yet.") + + logger.debug("Closing worker pool...") + handler = _ReportHandler(len(self._pool)) + self._callback = handler._handle + for w in self._pool: + self._quick_put((TASK_END, None)) + for w in self._pool: + w.join() + + logger.debug("Waiting for reports...") + if not handler.wait(2): + missing = handler.reports.index(None) + logger.warning( + "Didn't receive all worker reports before timeout. " + "Missing report from worker %d." % missing) + + logger.debug("Exiting result handler thread...") + self._result_queue.put(None) + self._result_handler.join() + self._closed = True + + return handler.reports + + @staticmethod + def _handleResults(pool): + while True: + try: + res = pool._quick_get() + except (EOFError, OSError): + logger.debug("Result handler thread encountered connection " + "problem, exiting.") + return + + if res is None: + logger.debug("Result handler exiting.") + break + + task_type, success, wid, data = res + try: + if success and pool._callback: + pool._callback(data) + elif not success: + if pool._error_callback: + pool._error_callback(data) + else: + logger.error(data) + except Exception as ex: + logger.exception(ex) + + if task_type == TASK_JOB: + pool._listener._onTaskDone() + + +class AsyncResult(object): + def __init__(self, pool, count): + self._pool = pool + self._count = count + self._event = threading.Event() + + def ready(self): + return self._event.is_set() + + def wait(self, timeout=None): + return self._event.wait(timeout) + + def _onTaskDone(self): + self._count -= 1 + if self._count == 0: + self._pool.setHandler(None) + self._pool._listener = None + self._event.set() + + +class _ReportHandler(object): + def __init__(self, worker_count): + self.reports = [None] * worker_count + self._count = worker_count + self._received = 0 + self._event = threading.Event() + + def wait(self, timeout=None): + return self._event.wait(timeout) + + def _handle(self, res): + wid, data = res + if wid < 0 or wid > self._count: + logger.error("Ignoring report from unknown worker %d." % wid) + return + + self._received += 1 + self.reports[wid] = data + + if self._received == self._count: + self._event.set() + + def _handleError(self, res): + wid, data = res + logger.error("Worker %d failed to send its report." % wid) + logger.exception(data) + + +class FastQueue(object): + def __init__(self, compress=False): + self._reader, self._writer = multiprocessing.Pipe(duplex=False) + self._rlock = multiprocessing.Lock() + self._wlock = multiprocessing.Lock() + self._compress = compress + + def __getstate__(self): + return (self._reader, self._writer, self._rlock, self._wlock, + self._compress) + + def __setstate__(self, state): + (self._reader, self._writer, self._rlock, self._wlock, + self._compress) = state + + def get(self): + with self._rlock: + raw = self._reader.recv_bytes() + if self._compress: + data = zlib.decompress(raw) + else: + data = raw + obj = unpickle(data) + return obj + + def put(self, obj): + data = pickle(obj) + if self._compress: + raw = zlib.compress(data) + else: + raw = data + with self._wlock: + self._writer.send_bytes(raw) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/basefs.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,104 @@ +import os.path +import yaml +from piecrust.app import PieCrust + + +class TestFileSystemBase(object): + def __init__(self): + pass + + def _initDefaultSpec(self): + self.withDir('counter') + self.withFile( + 'kitchen/config.yml', + "site:\n title: Mock Website\n") + + def path(self, p): + raise NotImplementedError() + + def getStructure(self, path=None): + raise NotImplementedError() + + def getFileEntry(self, path): + raise NotImplementedError() + + def _createDir(self, path): + raise NotImplementedError() + + def _createFile(self, path, contents): + raise NotImplementedError() + + def getApp(self, cache=True): + root_dir = self.path('/kitchen') + return PieCrust(root_dir, cache=cache, debug=True) + + def withDir(self, path): + path = path.replace('\\', '/') + path = path.lstrip('/') + path = '/%s/%s' % (self._root, path) + self._createDir(path) + return self + + def withFile(self, path, contents): + path = path.replace('\\', '/') + path = path.lstrip('/') + path = '/%s/%s' % (self._root, path) + self._createFile(path, contents) + return self + + def withAsset(self, path, contents): + return self.withFile('kitchen/' + path, contents) + + def withAssetDir(self, path): + return self.withDir('kitchen/' + path) + + def withConfig(self, config): + return self.withFile( + 'kitchen/config.yml', + yaml.dump(config)) + + def withThemeConfig(self, config): + return self.withFile( + 'kitchen/theme/theme_config.yml', + yaml.dump(config)) + + def withPage(self, url, config=None, contents=None): + config = config or {} + contents = contents or "A test page." + text = "---\n" + text += yaml.dump(config) + text += "---\n" + text += contents + + name, ext = os.path.splitext(url) + if not ext: + url += '.md' + url = url.lstrip('/') + return self.withAsset(url, text) + + def withPageAsset(self, page_url, name, contents=None): + contents = contents or "A test asset." + url_base, ext = os.path.splitext(page_url) + dirname = url_base + '-assets' + return self.withAsset( + '%s/%s' % (dirname, name), contents) + + def withPages(self, num, url_factory, config_factory=None, + contents_factory=None): + for i in range(num): + if isinstance(url_factory, str): + url = url_factory.format(idx=i, idx1=(i + 1)) + else: + url = url_factory(i) + + config = None + if config_factory: + config = config_factory(i) + + contents = None + if contents_factory: + contents = contents_factory(i) + + self.withPage(url, config, contents) + return self +
--- a/tests/conftest.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/conftest.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,5 +1,6 @@ import io import sys +import time import pprint import os.path import logging @@ -33,6 +34,8 @@ category = os.path.basename(path.dirname) if category == 'bakes': return BakeTestFile(path, parent) + elif category == 'procs': + return PipelineTestFile(path, parent) elif category == 'cli': return ChefTestFile(path, parent) elif category == 'servings': @@ -77,6 +80,37 @@ return fs +def check_expected_outputs(spec, fs, error_type): + cctx = CompareContext() + expected_output_files = spec.get('out') + if expected_output_files: + actual = fs.getStructure('kitchen/_counter') + error = _compare_dicts(expected_output_files, actual, cctx) + if error: + raise error_type(error) + + expected_partial_files = spec.get('outfiles') + if expected_partial_files: + keys = list(sorted(expected_partial_files.keys())) + for key in keys: + try: + actual = fs.getFileEntry('kitchen/_counter/' + + key.lstrip('/')) + except Exception as e: + raise error_type([ + "Can't access output file %s: %s" % (key, e)]) + + expected = expected_partial_files[key] + # HACK because for some reason PyYAML adds a new line for + # those and I have no idea why. + actual = actual.rstrip('\n') + expected = expected.rstrip('\n') + cctx.path = key + cmpres = _compare_str(expected, actual, cctx) + if cmpres: + raise error_type(cmpres) + + class ChefTestItem(YamlTestItemBase): __initialized_logging__ = False @@ -136,11 +170,6 @@ def runtest(self): fs = self._prepareMockFs() - # Output file-system. - expected_output_files = self.spec.get('out') - expected_partial_files = self.spec.get('outfiles') - - # Bake! from piecrust.baking.baker import Baker with mock_fs_scope(fs): out_dir = fs.path('kitchen/_counter') @@ -148,36 +177,13 @@ baker = Baker(app, out_dir) record = baker.bake() - if not record.success: - errors = [] - for e in record.entries: - errors += e.getAllErrors() - raise BakeError(errors) - - if expected_output_files: - actual = fs.getStructure('kitchen/_counter') - error = _compare_dicts(expected_output_files, actual) - if error: - raise ExpectedBakeOutputError(error) + if not record.success: + errors = [] + for e in record.entries: + errors += e.getAllErrors() + raise BakeError(errors) - if expected_partial_files: - keys = list(sorted(expected_partial_files.keys())) - for key in keys: - try: - actual = fs.getFileEntry('kitchen/_counter/' + - key.lstrip('/')) - except Exception as e: - raise ExpectedBakeOutputError([ - "Can't access output file %s: %s" % (key, e)]) - - expected = expected_partial_files[key] - # HACK because for some reason PyYAML adds a new line for those - # and I have no idea why. - actual = actual.rstrip('\n') - expected = expected.rstrip('\n') - cmpres = _compare_str(expected, actual, key) - if cmpres: - raise ExpectedBakeOutputError(cmpres) + check_expected_outputs(self.spec, fs, ExpectedBakeOutputError) def reportinfo(self): return self.fspath, 0, "bake: %s" % self.name @@ -207,6 +213,58 @@ __item_class__ = BakeTestItem +class PipelineTestItem(YamlTestItemBase): + def runtest(self): + fs = self._prepareMockFs() + + from piecrust.processing.pipeline import ProcessorPipeline + with mock_fs_scope(fs): + out_dir = fs.path('kitchen/_counter') + app = fs.getApp() + pipeline = ProcessorPipeline(app, out_dir) + + proc_names = self.spec.get('processors') + if proc_names: + pipeline.enabled_processors = proc_names + + record = pipeline.run() + + if not record.success: + errors = [] + for e in record.entries: + errors += e.errors + raise PipelineError(errors) + + check_expected_outputs(self.spec, fs, ExpectedPipelineOutputError) + + def reportinfo(self): + return self.fspath, 0, "pipeline: %s" % self.name + + def repr_failure(self, excinfo): + if isinstance(excinfo.value, ExpectedPipelineOutputError): + return ('\n'.join( + ['Unexpected pipeline output. Left is expected output, ' + 'right is actual output'] + + excinfo.value.args[0])) + elif isinstance(excinfo.value, PipelineError): + return ('\n'.join( + ['Errors occured during processing:'] + + excinfo.value.args[0])) + return super(PipelineTestItem, self).repr_failure(excinfo) + + +class PipelineError(Exception): + pass + + +class ExpectedPipelineOutputError(Exception): + pass + + +class PipelineTestFile(YamlTestFileBase): + __item_class__ = PipelineTestItem + + class ServeTestItem(YamlTestItemBase): class _TestApp(object): def __init__(self, server): @@ -265,65 +323,86 @@ _add_mock_files(fs, path, subspec) -def _compare(left, right, path): +class CompareContext(object): + def __init__(self, path=None, t=None): + self.path = path or '' + self.time = t or time.time() + + def createChildContext(self, name): + ctx = CompareContext( + path='%s/%s' % (self.path, name), + t=self.time) + return ctx + + +def _compare(left, right, ctx): if type(left) != type(right): return (["Different items: ", - "%s: %s" % (path, pprint.pformat(left)), - "%s: %s" % (path, pprint.pformat(right))]) + "%s: %s" % (ctx.path, pprint.pformat(left)), + "%s: %s" % (ctx.path, pprint.pformat(right))]) if isinstance(left, str): - return _compare_str(left, right, path) + return _compare_str(left, right, ctx) elif isinstance(left, dict): - return _compare_dicts(left, right, path) + return _compare_dicts(left, right, ctx) elif isinstance(left, list): - return _compare_lists(left, right, path) + return _compare_lists(left, right, ctx) elif left != right: return (["Different items: ", - "%s: %s" % (path, pprint.pformat(left)), - "%s: %s" % (path, pprint.pformat(right))]) + "%s: %s" % (ctx.path, pprint.pformat(left)), + "%s: %s" % (ctx.path, pprint.pformat(right))]) -def _compare_dicts(left, right, basepath=''): +def _compare_dicts(left, right, ctx): key_diff = set(left.keys()) ^ set(right.keys()) if key_diff: extra_left = set(left.keys()) - set(right.keys()) if extra_left: return (["Left contains more items: "] + - ['- %s/%s' % (basepath, k) for k in extra_left]) + ['- %s/%s' % (ctx.path, k) for k in extra_left]) extra_right = set(right.keys()) - set(left.keys()) if extra_right: return (["Right contains more items: "] + - ['- %s/%s' % (basepath, k) for k in extra_right]) + ['- %s/%s' % (ctx.path, k) for k in extra_right]) return ["Unknown difference"] for key in left.keys(): lv = left[key] rv = right[key] - childpath = basepath + '/' + key - cmpres = _compare(lv, rv, childpath) + child_ctx = ctx.createChildContext(key) + cmpres = _compare(lv, rv, child_ctx) if cmpres: return cmpres return None -def _compare_lists(left, right, path): +def _compare_lists(left, right, ctx): for i in range(min(len(left), len(right))): l = left[i] r = right[i] - cmpres = _compare(l, r, path) + cmpres = _compare(l, r, ctx) if cmpres: return cmpres if len(left) > len(right): - return (["Left '%s' contains more items. First extra item: " % path, - left[len(right)]]) + return (["Left '%s' contains more items. First extra item: " % + ctx.path, left[len(right)]]) if len(right) > len(left): - return (["Right '%s' contains more items. First extra item: " % path, - right[len(left)]]) + return (["Right '%s' contains more items. First extra item: " % + ctx.path, right[len(left)]]) return None -def _compare_str(left, right, path): +def _compare_str(left, right, ctx): if left == right: return None + + test_time_iso8601 = time.strftime('%Y-%m-%dT%H:%M:%SZ', + time.gmtime(ctx.time)) + replacements = { + '%test_time_iso8601%': test_time_iso8601} + for token, repl in replacements.items(): + left = left.replace(token, repl) + right = right.replace(token, repl) + for i in range(min(len(left), len(right))): if left[i] != right[i]: start = max(0, i - 15) @@ -346,7 +425,7 @@ if j < i: r_offset += len(c) - return ["Items '%s' differ at index %d:" % (path, i), '', + return ["Items '%s' differ at index %d:" % (ctx.path, i), '', "Left:", left, '', "Right:", right, '', "Difference:", @@ -354,12 +433,12 @@ r_str, (' ' * r_offset + '^')] if len(left) > len(right): return ["Left is longer.", - "Left '%s': " % path, left, - "Right '%s': " % path, right, + "Left '%s': " % ctx.path, left, + "Right '%s': " % ctx.path, right, "Extra items: %r" % left[len(right):]] if len(right) > len(left): return ["Right is longer.", - "Left '%s': " % path, left, - "Right '%s': " % path, right, + "Left '%s': " % ctx.path, left, + "Right '%s': " % ctx.path, right, "Extra items: %r" % right[len(left):]]
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/memfs.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,309 @@ +import os.path +import io +import time +import errno +import random +import codecs +import shutil +import mock +from piecrust import RESOURCES_DIR +from .basefs import TestFileSystemBase + + +class _MockFsEntry(object): + def __init__(self, contents): + self.contents = contents + self.metadata = {'mtime': time.time()} + + +class _MockFsEntryWriter(object): + def __init__(self, entry, mode='rt'): + self._entry = entry + self._mode = mode + + if 'b' in mode: + data = entry.contents + if isinstance(data, str): + data = data.encode('utf8') + self._stream = io.BytesIO(data) + else: + self._stream = io.StringIO(entry.contents) + + def __getattr__(self, name): + return getattr(self._stream, name) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + if 'w' in self._mode: + if 'a' in self._mode: + self._entry.contents += self._stream.getvalue() + else: + self._entry.contents = self._stream.getvalue() + self._entry.metadata['mtime'] = time.time() + self._stream.close() + + +class MemoryFileSystem(TestFileSystemBase): + def __init__(self, default_spec=True): + self._root = 'root_%d' % random.randrange(1000) + self._fs = {self._root: {}} + if default_spec: + self._initDefaultSpec() + + def path(self, p): + p = p.replace('\\', '/') + if p in ['/', '', None]: + return '/%s' % self._root + return '/%s/%s' % (self._root, p.lstrip('/')) + + def getStructure(self, path=None): + root = self._fs[self._root] + if path: + root = self._getEntry(self.path(path)) + if root is None: + raise Exception("No such path: %s" % path) + if not isinstance(root, dict): + raise Exception("Path is not a directory: %s" % path) + + res = {} + for k, v in root.items(): + self._getStructureRecursive(v, res, k) + return res + + def getFileEntry(self, path): + entry = self._getEntry(self.path(path)) + if entry is None: + raise Exception("No such file: %s" % path) + if not isinstance(entry, _MockFsEntry): + raise Exception("Path is not a file: %s" % path) + return entry.contents + + def _getStructureRecursive(self, src, target, name): + if isinstance(src, _MockFsEntry): + target[name] = src.contents + return + + e = {} + for k, v in src.items(): + self._getStructureRecursive(v, e, k) + target[name] = e + + def _getEntry(self, path): + cur = self._fs + path = path.replace('\\', '/').lstrip('/') + bits = path.split('/') + for p in bits: + try: + cur = cur[p] + except KeyError: + return None + return cur + + def _createDir(self, path): + cur = self._fs + path = path.replace('\\', '/').strip('/') + bits = path.split('/') + for b in bits: + if b not in cur: + cur[b] = {} + cur = cur[b] + return self + + def _createFile(self, path, contents): + cur = self._fs + path = path.replace('\\', '/').lstrip('/') + bits = path.split('/') + for b in bits[:-1]: + if b not in cur: + cur[b] = {} + cur = cur[b] + cur[bits[-1]] = _MockFsEntry(contents) + return self + + def _deleteEntry(self, path): + parent = self._getEntry(os.path.dirname(path)) + assert parent is not None + name = os.path.basename(path) + assert name in parent + del parent[name] + + +class MemoryScope(object): + def __init__(self, fs, open_patches=None): + self.open_patches = open_patches or [] + self._fs = fs + self._patchers = [] + self._originals = {} + + @property + def root(self): + return self._fs._root + + def __enter__(self): + self._startMock() + return self + + def __exit__(self, type, value, traceback): + self._endMock() + + def _startMock(self): + # TODO: sadly, there seems to be no way to replace `open` everywhere? + modules = self.open_patches + [ + '__main__', + 'piecrust.records', + 'jinja2.utils'] + for m in modules: + self._createMock('%s.open' % m, open, self._open, create=True) + + self._createMock('codecs.open', codecs.open, self._codecsOpen) + self._createMock('os.listdir', os.listdir, self._listdir) + self._createMock('os.makedirs', os.makedirs, self._makedirs) + self._createMock('os.remove', os.remove, self._remove) + self._createMock('os.rename', os.rename, self._rename) + self._createMock('os.path.exists', os.path.exists, self._exists) + self._createMock('os.path.isdir', os.path.isdir, self._isdir) + self._createMock('os.path.isfile', os.path.isfile, self._isfile) + self._createMock('os.path.islink', os.path.islink, self._islink) + self._createMock('os.path.getmtime', os.path.getmtime, self._getmtime) + self._createMock('shutil.copyfile', shutil.copyfile, self._copyfile) + self._createMock('shutil.rmtree', shutil.rmtree, self._rmtree) + for p in self._patchers: + p.start() + + def _endMock(self): + for p in self._patchers: + p.stop() + + def _createMock(self, name, orig, func, **kwargs): + self._originals[name] = orig + self._patchers.append(mock.patch(name, func, **kwargs)) + + def _doOpen(self, orig_name, path, mode, *args, **kwargs): + path = os.path.normpath(path) + if path.startswith(RESOURCES_DIR): + return self._originals[orig_name](path, mode, *args, **kwargs) + + if 'r' in mode: + e = self._getFsEntry(path) + elif 'w' in mode or 'x' in mode or 'a' in mode: + e = self._getFsEntry(path) + if e is None: + contents = '' + if 'b' in mode: + contents = bytes() + self._fs._createFile(path, contents) + e = self._getFsEntry(path) + assert e is not None + elif 'x' in mode: + err = IOError("File '%s' already exists" % path) + err.errno = errno.EEXIST + raise err + else: + err = IOError("Unsupported open mode: %s" % mode) + err.errno = errno.EINVAL + raise err + + if e is None: + err = IOError("No such file: %s" % path) + err.errno = errno.ENOENT + raise err + if not isinstance(e, _MockFsEntry): + err = IOError("'%s' is not a file %s" % (path, e)) + err.errno = errno.EISDIR + raise err + + return _MockFsEntryWriter(e, mode) + + def _open(self, path, mode, *args, **kwargs): + return self._doOpen('__main__.open', path, mode, *args, **kwargs) + + def _codecsOpen(self, path, mode, *args, **kwargs): + return self._doOpen('codecs.open', path, mode, *args, **kwargs) + + def _listdir(self, path): + path = os.path.normpath(path) + if path.startswith(RESOURCES_DIR): + return self._originals['os.listdir'](path) + + e = self._getFsEntry(path) + if e is None: + raise OSError("No such directory: %s" % path) + if not isinstance(e, dict): + raise OSError("'%s' is not a directory." % path) + return list(e.keys()) + + def _makedirs(self, path, mode=0o777): + if not path.replace('\\', '/').startswith('/' + self.root): + raise Exception("Shouldn't create directory: %s" % path) + self._fs._createDir(path) + + def _remove(self, path): + path = os.path.normpath(path) + self._fs._deleteEntry(path) + + def _exists(self, path): + path = os.path.normpath(path) + if path.startswith(RESOURCES_DIR): + return self._originals['os.path.isdir'](path) + e = self._getFsEntry(path) + return e is not None + + def _isdir(self, path): + path = os.path.normpath(path) + if path.startswith(RESOURCES_DIR): + return self._originals['os.path.isdir'](path) + e = self._getFsEntry(path) + return e is not None and isinstance(e, dict) + + def _isfile(self, path): + path = os.path.normpath(path) + if path.startswith(RESOURCES_DIR): + return self._originals['os.path.isfile'](path) + e = self._getFsEntry(path) + return e is not None and isinstance(e, _MockFsEntry) + + def _islink(self, path): + path = os.path.normpath(path) + if path.startswith(RESOURCES_DIR): + return self._originals['os.path.islink'](path) + return False + + def _getmtime(self, path): + path = os.path.normpath(path) + if path.startswith(RESOURCES_DIR): + return self._originals['os.path.getmtime'](path) + e = self._getFsEntry(path) + if e is None: + raise OSError("No such file: %s" % path) + return e.metadata['mtime'] + + def _copyfile(self, src, dst): + src = os.path.normpath(src) + if src.startswith(RESOURCES_DIR): + with self._originals['__main__.open'](src, 'r') as fp: + src_text = fp.read() + else: + e = self._getFsEntry(src) + src_text = e.contents + if not dst.replace('\\', '/').startswith('/' + self.root): + raise Exception("Shouldn't copy to: %s" % dst) + self._fs._createFile(dst, src_text) + + def _rename(self, src, dst): + src = os.path.normpath(src) + if src.startswith(RESOURCES_DIR) or dst.startswith(RESOURCES_DIR): + raise Exception("Shouldn't rename files in the resources path.") + self._copyfile(src, dst) + self._remove(src) + + def _rmtree(self, path): + if not path.replace('\\', '/').startswith('/' + self.root): + raise Exception("Shouldn't delete trees from: %s" % path) + e = self._fs._getEntry(os.path.dirname(path)) + del e[os.path.basename(path)] + + def _getFsEntry(self, path): + return self._fs._getEntry(path) +
--- a/tests/mockutil.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/mockutil.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,23 +1,10 @@ -import io -import time -import errno -import random -import codecs -import shutil import os.path import mock -import yaml from piecrust.app import PieCrust, PieCrustConfiguration from piecrust.page import Page from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page -resources_path = os.path.abspath( - os.path.join( - os.path.dirname(__file__), - '..', 'piecrust', 'resources')) - - def get_mock_app(config=None): app = mock.MagicMock(spec=PieCrust) app.config = PieCrustConfiguration() @@ -37,376 +24,7 @@ return rp.content -class _MockFsEntry(object): - def __init__(self, contents): - self.contents = contents - self.metadata = {'mtime': time.time()} - - -class _MockFsEntryWriter(object): - def __init__(self, entry, mode='rt'): - self._entry = entry - self._mode = mode - - if 'b' in mode: - data = entry.contents - if isinstance(data, str): - data = data.encode('utf8') - self._stream = io.BytesIO(data) - else: - self._stream = io.StringIO(entry.contents) - - def __getattr__(self, name): - return getattr(self._stream, name) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, exc_tb): - if 'w' in self._mode: - if 'a' in self._mode: - self._entry.contents += self._stream.getvalue() - else: - self._entry.contents = self._stream.getvalue() - self._entry.metadata['mtime'] = time.time() - self._stream.close() - - -class mock_fs(object): - def __init__(self, default_spec=True): - self._root = 'root_%d' % random.randrange(1000) - self._fs = {self._root: {}} - if default_spec: - self.withDir('counter') - self.withFile('kitchen/config.yml', - "site:\n title: Mock Website\n") - - def path(self, p): - p = p.replace('\\', '/') - if p in ['/', '', None]: - return '/%s' % self._root - return '/%s/%s' % (self._root, p.lstrip('/')) - - def getApp(self, cache=True): - root_dir = self.path('/kitchen') - return PieCrust(root_dir, cache=cache, debug=True) - - def withDir(self, path): - path = path.replace('\\', '/') - path = path.lstrip('/') - path = '/%s/%s' % (self._root, path) - self._createDir(path) - return self - - def withFile(self, path, contents): - path = path.replace('\\', '/') - path = path.lstrip('/') - path = '/%s/%s' % (self._root, path) - self._createFile(path, contents) - return self - - def withAsset(self, path, contents): - return self.withFile('kitchen/' + path, contents) - - def withAssetDir(self, path): - return self.withDir('kitchen/' + path) - - def withConfig(self, config): - return self.withFile( - 'kitchen/config.yml', - yaml.dump(config)) - - def withThemeConfig(self, config): - return self.withFile( - 'kitchen/theme/theme_config.yml', - yaml.dump(config)) - - def withPage(self, url, config=None, contents=None): - config = config or {} - contents = contents or "A test page." - text = "---\n" - text += yaml.dump(config) - text += "---\n" - text += contents - - name, ext = os.path.splitext(url) - if not ext: - url += '.md' - url = url.lstrip('/') - return self.withAsset(url, text) - - def withPageAsset(self, page_url, name, contents=None): - contents = contents or "A test asset." - url_base, ext = os.path.splitext(page_url) - dirname = url_base + '-assets' - return self.withAsset('%s/%s' % (dirname, name), - contents) - - def withPages(self, num, url_factory, config_factory=None, - contents_factory=None): - for i in range(num): - if isinstance(url_factory, str): - url = url_factory.format(idx=i, idx1=(i + 1)) - else: - url = url_factory(i) - - config = None - if config_factory: - config = config_factory(i) - - contents = None - if contents_factory: - contents = contents_factory(i) - - self.withPage(url, config, contents) - return self - - def getStructure(self, path=None): - root = self._fs[self._root] - if path: - root = self._getEntry(self.path(path)) - if root is None: - raise Exception("No such path: %s" % path) - if not isinstance(root, dict): - raise Exception("Path is not a directory: %s" % path) - - res = {} - for k, v in root.items(): - self._getStructureRecursive(v, res, k) - return res - - def getFileEntry(self, path): - entry = self._getEntry(self.path(path)) - if entry is None: - raise Exception("No such file: %s" % path) - if not isinstance(entry, _MockFsEntry): - raise Exception("Path is not a file: %s" % path) - return entry.contents - - def _getStructureRecursive(self, src, target, name): - if isinstance(src, _MockFsEntry): - target[name] = src.contents - return - - e = {} - for k, v in src.items(): - self._getStructureRecursive(v, e, k) - target[name] = e - - def _getEntry(self, path): - cur = self._fs - path = path.replace('\\', '/').lstrip('/') - bits = path.split('/') - for p in bits: - try: - cur = cur[p] - except KeyError: - return None - return cur - - def _createDir(self, path): - cur = self._fs - path = path.replace('\\', '/').strip('/') - bits = path.split('/') - for b in bits: - if b not in cur: - cur[b] = {} - cur = cur[b] - return self +from .tmpfs import ( + TempDirFileSystem as mock_fs, + TempDirScope as mock_fs_scope) - def _createFile(self, path, contents): - cur = self._fs - path = path.replace('\\', '/').lstrip('/') - bits = path.split('/') - for b in bits[:-1]: - if b not in cur: - cur[b] = {} - cur = cur[b] - cur[bits[-1]] = _MockFsEntry(contents) - return self - - def _deleteEntry(self, path): - parent = self._getEntry(os.path.dirname(path)) - assert parent is not None - name = os.path.basename(path) - assert name in parent - del parent[name] - - -class mock_fs_scope(object): - def __init__(self, fs, open_patches=None): - self.open_patches = open_patches or [] - self._fs = fs - self._patchers = [] - self._originals = {} - - @property - def root(self): - return self._fs._root - - def __enter__(self): - self._startMock() - return self - - def __exit__(self, type, value, traceback): - self._endMock() - - def _startMock(self): - # TODO: sadly, there seems to be no way to replace `open` everywhere? - modules = self.open_patches + [ - '__main__', - 'piecrust.records', - 'jinja2.utils'] - for m in modules: - self._createMock('%s.open' % m, open, self._open, create=True) - - self._createMock('codecs.open', codecs.open, self._codecsOpen) - self._createMock('os.listdir', os.listdir, self._listdir) - self._createMock('os.makedirs', os.makedirs, self._makedirs) - self._createMock('os.remove', os.remove, self._remove) - self._createMock('os.rename', os.rename, self._rename) - self._createMock('os.path.exists', os.path.exists, self._exists) - self._createMock('os.path.isdir', os.path.isdir, self._isdir) - self._createMock('os.path.isfile', os.path.isfile, self._isfile) - self._createMock('os.path.islink', os.path.islink, self._islink) - self._createMock('os.path.getmtime', os.path.getmtime, self._getmtime) - self._createMock('shutil.copyfile', shutil.copyfile, self._copyfile) - self._createMock('shutil.rmtree', shutil.rmtree, self._rmtree) - for p in self._patchers: - p.start() - - def _endMock(self): - for p in self._patchers: - p.stop() - - def _createMock(self, name, orig, func, **kwargs): - self._originals[name] = orig - self._patchers.append(mock.patch(name, func, **kwargs)) - - def _doOpen(self, orig_name, path, mode, *args, **kwargs): - path = os.path.normpath(path) - if path.startswith(resources_path): - return self._originals[orig_name](path, mode, *args, **kwargs) - - if 'r' in mode: - e = self._getFsEntry(path) - elif 'w' in mode or 'x' in mode or 'a' in mode: - e = self._getFsEntry(path) - if e is None: - contents = '' - if 'b' in mode: - contents = bytes() - self._fs._createFile(path, contents) - e = self._getFsEntry(path) - assert e is not None - elif 'x' in mode: - err = IOError("File '%s' already exists" % path) - err.errno = errno.EEXIST - raise err - else: - err = IOError("Unsupported open mode: %s" % mode) - err.errno = errno.EINVAL - raise err - - if e is None: - err = IOError("No such file: %s" % path) - err.errno = errno.ENOENT - raise err - if not isinstance(e, _MockFsEntry): - err = IOError("'%s' is not a file %s" % (path, e)) - err.errno = errno.EISDIR - raise err - - return _MockFsEntryWriter(e, mode) - - def _open(self, path, mode, *args, **kwargs): - return self._doOpen('__main__.open', path, mode, *args, **kwargs) - - def _codecsOpen(self, path, mode, *args, **kwargs): - return self._doOpen('codecs.open', path, mode, *args, **kwargs) - - def _listdir(self, path): - path = os.path.normpath(path) - if path.startswith(resources_path): - return self._originals['os.listdir'](path) - - e = self._getFsEntry(path) - if e is None: - raise OSError("No such directory: %s" % path) - if not isinstance(e, dict): - raise OSError("'%s' is not a directory." % path) - return list(e.keys()) - - def _makedirs(self, path, mode=0o777): - if not path.replace('\\', '/').startswith('/' + self.root): - raise Exception("Shouldn't create directory: %s" % path) - self._fs._createDir(path) - - def _remove(self, path): - path = os.path.normpath(path) - self._fs._deleteEntry(path) - - def _exists(self, path): - path = os.path.normpath(path) - if path.startswith(resources_path): - return self._originals['os.path.isdir'](path) - e = self._getFsEntry(path) - return e is not None - - def _isdir(self, path): - path = os.path.normpath(path) - if path.startswith(resources_path): - return self._originals['os.path.isdir'](path) - e = self._getFsEntry(path) - return e is not None and isinstance(e, dict) - - def _isfile(self, path): - path = os.path.normpath(path) - if path.startswith(resources_path): - return self._originals['os.path.isfile'](path) - e = self._getFsEntry(path) - return e is not None and isinstance(e, _MockFsEntry) - - def _islink(self, path): - path = os.path.normpath(path) - if path.startswith(resources_path): - return self._originals['os.path.islink'](path) - return False - - def _getmtime(self, path): - path = os.path.normpath(path) - if path.startswith(resources_path): - return self._originals['os.path.getmtime'](path) - e = self._getFsEntry(path) - if e is None: - raise OSError("No such file: %s" % path) - return e.metadata['mtime'] - - def _copyfile(self, src, dst): - src = os.path.normpath(src) - if src.startswith(resources_path): - with self._originals['__main__.open'](src, 'r') as fp: - src_text = fp.read() - else: - e = self._getFsEntry(src) - src_text = e.contents - if not dst.replace('\\', '/').startswith('/' + self.root): - raise Exception("Shouldn't copy to: %s" % dst) - self._fs._createFile(dst, src_text) - - def _rename(self, src, dst): - src = os.path.normpath(src) - if src.startswith(resources_path) or dst.startswith(resources_path): - raise Exception("Shouldn't rename files in the resources path.") - self._copyfile(src, dst) - self._remove(src) - - def _rmtree(self, path): - if not path.replace('\\', '/').startswith('/' + self.root): - raise Exception("Shouldn't delete trees from: %s" % path) - e = self._fs._getEntry(os.path.dirname(path)) - del e[os.path.basename(path)] - - def _getFsEntry(self, path): - return self._fs._getEntry(path) -
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/procs/test_sitemap.yaml Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,40 @@ +--- +in: + assets/sitemap.sitemap: | + autogen: [pages, theme_pages] + pages/foo.md: This is a foo +outfiles: + sitemap.xml: | + <?xml version="1.0" encoding="utf-8"?> + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>/foo.html</loc> + <lastmod>%test_time_iso8601%</lastmod> + </url> + <url> + <loc>/</loc> + <lastmod>%test_time_iso8601%</lastmod> + </url> + </urlset> +--- +in: + assets/sitemap.sitemap: | + autogen: [pages] + pages/foo.md: | + --- + sitemap: + changefreq: monthly + priority: 0.8 + --- + This is a foo +outfiles: + sitemap.xml: | + <?xml version="1.0" encoding="utf-8"?> + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>/foo.html</loc> + <lastmod>%test_time_iso8601%</lastmod> + <changefreq>monthly</changefreq> + <priority>0.8</priority> + </url> + </urlset>
--- a/tests/test_baking_baker.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_baking_baker.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,7 +1,8 @@ import time import os.path import pytest -from piecrust.baking.baker import PageBaker, Baker +from piecrust.baking.baker import Baker +from piecrust.baking.single import PageBaker from piecrust.baking.records import BakeRecord from .mockutil import get_mock_app, mock_fs, mock_fs_scope @@ -56,6 +57,7 @@ with mock_fs_scope(fs): out_dir = fs.path('kitchen/_counter') app = fs.getApp() + app.config.set('baker/workers', 1) baker = Baker(app, out_dir) baker.bake() structure = fs.getStructure('kitchen/_counter')
--- a/tests/test_configuration.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_configuration.py Sat Jul 11 20:33:55 2015 -0700 @@ -13,13 +13,13 @@ ]) def test_config_init(values, expected): config = Configuration(values) - assert config.get() == expected + assert config.getAll() == expected def test_config_set_all(): config = Configuration() config.setAll({'foo': 'bar'}) - assert config.get() == {'foo': 'bar'} + assert config.getAll() == {'foo': 'bar'} def test_config_get_and_set(): @@ -125,7 +125,7 @@ 'child10': 'ten' } } - assert config.get() == expected + assert config.getAll() == expected def test_ordered_loader():
--- a/tests/test_data_assetor.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_data_assetor.py Sat Jul 11 20:33:55 2015 -0700 @@ -5,34 +5,35 @@ from .mockutil import mock_fs, mock_fs_scope -@pytest.mark.parametrize('fs, site_root, expected', [ - (mock_fs().withPage('pages/foo/bar'), '/', {}), - (mock_fs() +@pytest.mark.parametrize('fs_fac, site_root, expected', [ + (lambda: mock_fs().withPage('pages/foo/bar'), '/', {}), + (lambda: mock_fs() .withPage('pages/foo/bar') .withPageAsset('pages/foo/bar', 'one.txt', 'one'), '/', {'one': 'one'}), - (mock_fs() + (lambda: mock_fs() .withPage('pages/foo/bar') .withPageAsset('pages/foo/bar', 'one.txt', 'one') .withPageAsset('pages/foo/bar', 'two.txt', 'two'), '/', {'one': 'one', 'two': 'two'}), - (mock_fs().withPage('pages/foo/bar'), '/whatever', {}), - (mock_fs() + (lambda: mock_fs().withPage('pages/foo/bar'), '/whatever', {}), + (lambda: mock_fs() .withPage('pages/foo/bar') .withPageAsset('pages/foo/bar', 'one.txt', 'one'), '/whatever', {'one': 'one'}), - (mock_fs() + (lambda: mock_fs() .withPage('pages/foo/bar') .withPageAsset('pages/foo/bar', 'one.txt', 'one') .withPageAsset('pages/foo/bar', 'two.txt', 'two'), '/whatever', {'one': 'one', 'two': 'two'}) ]) -def test_assets(fs, site_root, expected): +def test_assets(fs_fac, site_root, expected): + fs = fs_fac() fs.withConfig({'site': {'root': site_root}}) with mock_fs_scope(fs): page = MagicMock()
--- a/tests/test_data_linker.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_data_linker.py Sat Jul 11 20:33:55 2015 -0700 @@ -5,17 +5,17 @@ @pytest.mark.parametrize( - 'fs, page_path, expected', + 'fs_fac, page_path, expected', [ - (mock_fs().withPage('pages/foo'), 'foo.md', + (lambda: mock_fs().withPage('pages/foo'), 'foo.md', # is_dir, name, is_self, data [(False, 'foo', True, '/foo')]), - ((mock_fs() + ((lambda: mock_fs() .withPage('pages/foo') .withPage('pages/bar')), 'foo.md', [(False, 'bar', False, '/bar'), (False, 'foo', True, '/foo')]), - ((mock_fs() + ((lambda: mock_fs() .withPage('pages/baz') .withPage('pages/something') .withPage('pages/something/else') @@ -26,7 +26,7 @@ (False, 'baz', False, '/baz'), (False, 'foo', True, '/foo'), (True, 'something', False, '/something')]), - ((mock_fs() + ((lambda: mock_fs() .withPage('pages/something/else') .withPage('pages/foo') .withPage('pages/something/good') @@ -35,7 +35,8 @@ [(False, 'else', True, '/something/else'), (False, 'good', False, '/something/good')]) ]) -def test_linker_iteration(fs, page_path, expected): +def test_linker_iteration(fs_fac, page_path, expected): + fs = fs_fac() with mock_fs_scope(fs): app = fs.getApp() app.config.set('site/pretty_urls', True) @@ -54,16 +55,16 @@ @pytest.mark.parametrize( - 'fs, page_path, expected', + 'fs_fac, page_path, expected', [ - (mock_fs().withPage('pages/foo'), 'foo.md', + (lambda: mock_fs().withPage('pages/foo'), 'foo.md', [('/foo', True)]), - ((mock_fs() + ((lambda: mock_fs() .withPage('pages/foo') .withPage('pages/bar')), 'foo.md', [('/bar', False), ('/foo', True)]), - ((mock_fs() + ((lambda: mock_fs() .withPage('pages/baz') .withPage('pages/something/else') .withPage('pages/foo') @@ -71,7 +72,7 @@ 'foo.md', [('/bar', False), ('/baz', False), ('/foo', True), ('/something/else', False)]), - ((mock_fs() + ((lambda: mock_fs() .withPage('pages/something/else') .withPage('pages/foo') .withPage('pages/something/good') @@ -80,7 +81,8 @@ [('/something/else', True), ('/something/good', False)]) ]) -def test_recursive_linker_iteration(fs, page_path, expected): +def test_recursive_linker_iteration(fs_fac, page_path, expected): + fs = fs_fac() with mock_fs_scope(fs): app = fs.getApp() app.config.set('site/pretty_urls', True)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/test_fastpickle.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,53 @@ +import datetime +import pytest +from piecrust.fastpickle import pickle, unpickle + + +class Foo(object): + def __init__(self, name): + self.name = name + self.bars = [] + + +class Bar(object): + def __init__(self, value): + self.value = value + + +@pytest.mark.parametrize( + 'obj, expected', + [ + (True, True), + (42, 42), + (3.14, 3.14), + (datetime.date(2015, 5, 21), datetime.date(2015, 5, 21)), + (datetime.datetime(2015, 5, 21, 12, 55, 32), + datetime.datetime(2015, 5, 21, 12, 55, 32)), + (datetime.time(9, 25, 57), datetime.time(9, 25, 57)), + ((1, 2, 3), (1, 2, 3)), + ([1, 2, 3], [1, 2, 3]), + ({'foo': 1, 'bar': 2}, {'foo': 1, 'bar': 2}), + (set([1, 2, 3]), set([1, 2, 3])), + ({'foo': [1, 2, 3], 'bar': {'one': 1, 'two': 2}}, + {'foo': [1, 2, 3], 'bar': {'one': 1, 'two': 2}}) + ]) +def test_pickle_unpickle(obj, expected): + data = pickle(obj) + actual = unpickle(data) + assert actual == expected + + +def test_objects(): + f = Foo('foo') + f.bars.append(Bar(1)) + f.bars.append(Bar(2)) + + data = pickle(f) + o = unpickle(data) + + assert type(o) == Foo + assert o.name == 'foo' + assert len(o.bars) == 2 + for i in range(2): + assert f.bars[i].value == o.bars[i].value +
--- a/tests/test_processing_base.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_processing_base.py Sat Jul 11 20:33:55 2015 -0700 @@ -2,8 +2,10 @@ import os.path import shutil import pytest -from piecrust.processing.base import (ProcessorPipeline, SimpleFileProcessor) +from piecrust.processing.base import SimpleFileProcessor +from piecrust.processing.pipeline import ProcessorPipeline from piecrust.processing.records import ProcessorPipelineRecord +from piecrust.processing.worker import get_filtered_processors from .mockutil import mock_fs, mock_fs_scope @@ -36,7 +38,6 @@ def _get_pipeline(fs, app=None): app = app or fs.getApp() - app.config.set('baker/num_workers', 1) return ProcessorPipeline(app, fs.path('counter')) @@ -44,7 +45,7 @@ fs = mock_fs() with mock_fs_scope(fs): pp = _get_pipeline(fs) - pp.filterProcessors(['copy']) + pp.enabled_processors = ['copy'] expected = {} assert expected == fs.getStructure('counter') pp.run() @@ -57,7 +58,7 @@ .withFile('kitchen/assets/something.html', 'A test file.')) with mock_fs_scope(fs): pp = _get_pipeline(fs) - pp.filterProcessors(['copy']) + pp.enabled_processors = ['copy'] expected = {} assert expected == fs.getStructure('counter') pp.run() @@ -70,17 +71,19 @@ .withFile('kitchen/assets/blah.foo', 'A test file.')) with mock_fs_scope(fs): pp = _get_pipeline(fs) - pp.filterProcessors(['copy']) + pp.enabled_processors = ['copy'] pp.run() expected = {'blah.foo': 'A test file.'} assert expected == fs.getStructure('counter') mtime = os.path.getmtime(fs.path('/counter/blah.foo')) assert abs(time.time() - mtime) <= 2 + time.sleep(1) pp.run() assert expected == fs.getStructure('counter') assert mtime == os.path.getmtime(fs.path('/counter/blah.foo')) + time.sleep(1) fs.withFile('kitchen/assets/blah.foo', 'A new test file.') pp.run() expected = {'blah.foo': 'A new test file.'} @@ -91,20 +94,22 @@ def test_two_levels_dirtyness(): fs = (mock_fs() .withFile('kitchen/assets/blah.foo', 'A test file.')) - with mock_fs_scope(fs) as scope: + with mock_fs_scope(fs): pp = _get_pipeline(fs) - pp.processors.append(FooProcessor(('foo', 'bar'), scope._open)) - pp.filterProcessors(['foo', 'copy']) + pp.enabled_processors = ['copy'] + pp.additional_processors = [FooProcessor(('foo', 'bar'))] pp.run() expected = {'blah.bar': 'FOO: A test file.'} assert expected == fs.getStructure('counter') mtime = os.path.getmtime(fs.path('/counter/blah.bar')) assert abs(time.time() - mtime) <= 2 + time.sleep(1) pp.run() assert expected == fs.getStructure('counter') assert mtime == os.path.getmtime(fs.path('/counter/blah.bar')) + time.sleep(1) fs.withFile('kitchen/assets/blah.foo', 'A new test file.') pp.run() expected = {'blah.bar': 'FOO: A new test file.'} @@ -122,10 +127,11 @@ 'blah2.foo': 'Ooops'} assert expected == fs.getStructure('kitchen/assets') pp = _get_pipeline(fs) - pp.filterProcessors(['copy']) + pp.enabled_processors = ['copy'] pp.run() assert expected == fs.getStructure('counter') + time.sleep(1) os.remove(fs.path('/kitchen/assets/blah2.foo')) expected = { 'blah1.foo': 'A test file.'} @@ -140,18 +146,21 @@ with mock_fs_scope(fs): pp = _get_pipeline(fs) noop = NoopProcessor(('foo', 'foo')) - pp.processors.append(noop) - pp.filterProcessors(['foo', 'copy']) + pp.enabled_processors = ['copy'] + pp.additional_processors = [noop] pp.run() - assert 1 == len(noop.processed) + assert os.path.exists(fs.path('/counter/blah.foo')) is True + mtime = os.path.getmtime(fs.path('/counter/blah.foo')) + time.sleep(1) pp.run() - assert 1 == len(noop.processed) + assert mtime == os.path.getmtime(fs.path('/counter/blah.foo')) + time.sleep(1) ProcessorPipelineRecord.RECORD_VERSION += 1 try: pp.run() - assert 2 == len(noop.processed) + assert mtime < os.path.getmtime(fs.path('/counter/blah.foo')) finally: ProcessorPipelineRecord.RECORD_VERSION -= 1 @@ -165,24 +174,27 @@ {'something.html': 'A test file.', 'foo': {'_important.html': 'Important!'}}) ]) -def test_skip_pattern(patterns, expected): +def test_ignore_pattern(patterns, expected): fs = (mock_fs() .withFile('kitchen/assets/something.html', 'A test file.') .withFile('kitchen/assets/_hidden.html', 'Shhh') .withFile('kitchen/assets/foo/_important.html', 'Important!')) with mock_fs_scope(fs): pp = _get_pipeline(fs) - pp.addSkipPatterns(patterns) - pp.filterProcessors(['copy']) + pp.addIgnorePatterns(patterns) + pp.enabled_processors = ['copy'] assert {} == fs.getStructure('counter') pp.run() assert expected == fs.getStructure('counter') @pytest.mark.parametrize('names, expected', [ - ('all', ['copy', 'concat', 'less', 'sass', 'sitemap']), - ('all -sitemap', ['copy', 'concat', 'less', 'sass']), - ('-sitemap -less -sass all', ['copy', 'concat']), + ('all', ['cleancss', 'compass', 'copy', 'concat', 'less', 'requirejs', + 'sass', 'sitemap', 'uglifyjs']), + ('all -sitemap', ['cleancss', 'copy', 'compass', 'concat', 'less', + 'requirejs', 'sass', 'uglifyjs']), + ('-sitemap -less -sass all', ['cleancss', 'copy', 'compass', 'concat', + 'requirejs', 'uglifyjs']), ('copy', ['copy']), ('less sass', ['less', 'sass']) ]) @@ -190,9 +202,8 @@ fs = mock_fs() with mock_fs_scope(fs): app = fs.getApp() - pp = _get_pipeline(fs, app=app) - pp.filterProcessors('copy concat less sass sitemap') - procs = pp.getFilteredProcessors(names) + processors = app.plugin_loader.getProcessors() + procs = get_filtered_processors(processors, names) actual = [p.PROCESSOR_NAME for p in procs] assert sorted(actual) == sorted(expected)
--- a/tests/test_sources_autoconfig.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_sources_autoconfig.py Sat Jul 11 20:33:55 2015 -0700 @@ -5,49 +5,49 @@ @pytest.mark.parametrize( - 'fs, src_config, expected_paths, expected_metadata', + 'fs_fac, src_config, expected_paths, expected_metadata', [ - (mock_fs(), {}, [], []), - (mock_fs().withPage('test/_index.md'), + (lambda: mock_fs(), {}, [], []), + (lambda: mock_fs().withPage('test/_index.md'), {}, ['_index.md'], [{'slug': '', 'config': {'foo': []}}]), - (mock_fs().withPage('test/something.md'), + (lambda: mock_fs().withPage('test/something.md'), {}, ['something.md'], [{'slug': 'something', 'config': {'foo': []}}]), - (mock_fs().withPage('test/bar/something.md'), + (lambda: mock_fs().withPage('test/bar/something.md'), {}, ['bar/something.md'], [{'slug': 'something', 'config': {'foo': ['bar']}}]), - (mock_fs().withPage('test/bar1/bar2/something.md'), + (lambda: mock_fs().withPage('test/bar1/bar2/something.md'), {}, ['bar1/bar2/something.md'], [{'slug': 'something', 'config': {'foo': ['bar1', 'bar2']}}]), - (mock_fs().withPage('test/something.md'), + (lambda: mock_fs().withPage('test/something.md'), {'collapse_single_values': True}, ['something.md'], [{'slug': 'something', 'config': {'foo': None}}]), - (mock_fs().withPage('test/bar/something.md'), + (lambda: mock_fs().withPage('test/bar/something.md'), {'collapse_single_values': True}, ['bar/something.md'], [{'slug': 'something', 'config': {'foo': 'bar'}}]), - (mock_fs().withPage('test/bar1/bar2/something.md'), + (lambda: mock_fs().withPage('test/bar1/bar2/something.md'), {'collapse_single_values': True}, ['bar1/bar2/something.md'], [{'slug': 'something', 'config': {'foo': ['bar1', 'bar2']}}]), - (mock_fs().withPage('test/something.md'), + (lambda: mock_fs().withPage('test/something.md'), {'only_single_values': True}, ['something.md'], [{'slug': 'something', 'config': {'foo': None}}]), - (mock_fs().withPage('test/bar/something.md'), + (lambda: mock_fs().withPage('test/bar/something.md'), {'only_single_values': True}, ['bar/something.md'], [{'slug': 'something', 'config': {'foo': 'bar'}}]), ]) -def test_autoconfig_source_factories(fs, src_config, expected_paths, +def test_autoconfig_source_factories(fs_fac, src_config, expected_paths, expected_metadata): site_config = { 'sources': { @@ -58,6 +58,7 @@ {'url': '/%slug%', 'source': 'test'}] } site_config['sources']['test'].update(src_config) + fs = fs_fac() fs.withConfig({'site': site_config}) fs.withDir('kitchen/test') with mock_fs_scope(fs): @@ -88,27 +89,27 @@ @pytest.mark.parametrize( - 'fs, expected_paths, expected_metadata', + 'fs_fac, expected_paths, expected_metadata', [ - (mock_fs(), [], []), - (mock_fs().withPage('test/_index.md'), + (lambda: mock_fs(), [], []), + (lambda: mock_fs().withPage('test/_index.md'), ['_index.md'], [{'slug': '', 'config': {'foo': 0, 'foo_trail': [0]}}]), - (mock_fs().withPage('test/something.md'), + (lambda: mock_fs().withPage('test/something.md'), ['something.md'], [{'slug': 'something', 'config': {'foo': 0, 'foo_trail': [0]}}]), - (mock_fs().withPage('test/08_something.md'), + (lambda: mock_fs().withPage('test/08_something.md'), ['08_something.md'], [{'slug': 'something', 'config': {'foo': 8, 'foo_trail': [8]}}]), - (mock_fs().withPage('test/02_there/08_something.md'), + (lambda: mock_fs().withPage('test/02_there/08_something.md'), ['02_there/08_something.md'], [{'slug': 'there/something', 'config': {'foo': 8, 'foo_trail': [2, 8]}}]), ]) -def test_ordered_source_factories(fs, expected_paths, expected_metadata): +def test_ordered_source_factories(fs_fac, expected_paths, expected_metadata): site_config = { 'sources': { 'test': {'type': 'ordered', @@ -117,6 +118,7 @@ 'routes': [ {'url': '/%slug%', 'source': 'test'}] } + fs = fs_fac() fs.withConfig({'site': site_config}) fs.withDir('kitchen/test') with mock_fs_scope(fs): @@ -130,34 +132,34 @@ @pytest.mark.parametrize( - 'fs, route_path, expected_path, expected_metadata', + 'fs_fac, route_path, expected_path, expected_metadata', [ - (mock_fs(), 'missing', None, None), - (mock_fs().withPage('test/something.md'), + (lambda: mock_fs(), 'missing', None, None), + (lambda: mock_fs().withPage('test/something.md'), 'something', 'something.md', {'slug': 'something', 'config': {'foo': 0, 'foo_trail': [0]}}), - (mock_fs().withPage('test/bar/something.md'), + (lambda: mock_fs().withPage('test/bar/something.md'), 'bar/something', 'bar/something.md', {'slug': 'bar/something', 'config': {'foo': 0, 'foo_trail': [0]}}), - (mock_fs().withPage('test/42_something.md'), + (lambda: mock_fs().withPage('test/42_something.md'), 'something', '42_something.md', {'slug': 'something', 'config': {'foo': 42, 'foo_trail': [42]}}), - (mock_fs().withPage('test/bar/42_something.md'), + (lambda: mock_fs().withPage('test/bar/42_something.md'), 'bar/something', 'bar/42_something.md', {'slug': 'bar/something', 'config': {'foo': 42, 'foo_trail': [42]}}), - ((mock_fs() + ((lambda: mock_fs() .withPage('test/42_something.md') .withPage('test/43_other_something.md')), 'something', '42_something.md', {'slug': 'something', 'config': {'foo': 42, 'foo_trail': [42]}}), ]) -def test_ordered_source_find(fs, route_path, expected_path, +def test_ordered_source_find(fs_fac, route_path, expected_path, expected_metadata): site_config = { 'sources': { @@ -167,6 +169,7 @@ 'routes': [ {'url': '/%slug%', 'source': 'test'}] } + fs = fs_fac() fs.withConfig({'site': site_config}) fs.withDir('kitchen/test') with mock_fs_scope(fs):
--- a/tests/test_sources_base.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_sources_base.py Sat Jul 11 20:33:55 2015 -0700 @@ -6,22 +6,23 @@ from .pathutil import slashfix -@pytest.mark.parametrize('fs, expected_paths, expected_slugs', [ - (mock_fs(), [], []), - (mock_fs().withPage('test/foo.html'), +@pytest.mark.parametrize('fs_fac, expected_paths, expected_slugs', [ + (lambda: mock_fs(), [], []), + (lambda: mock_fs().withPage('test/foo.html'), ['foo.html'], ['foo']), - (mock_fs().withPage('test/foo.md'), + (lambda: mock_fs().withPage('test/foo.md'), ['foo.md'], ['foo']), - (mock_fs().withPage('test/foo.ext'), + (lambda: mock_fs().withPage('test/foo.ext'), ['foo.ext'], ['foo.ext']), - (mock_fs().withPage('test/foo/bar.html'), + (lambda: mock_fs().withPage('test/foo/bar.html'), ['foo/bar.html'], ['foo/bar']), - (mock_fs().withPage('test/foo/bar.md'), + (lambda: mock_fs().withPage('test/foo/bar.md'), ['foo/bar.md'], ['foo/bar']), - (mock_fs().withPage('test/foo/bar.ext'), + (lambda: mock_fs().withPage('test/foo/bar.ext'), ['foo/bar.ext'], ['foo/bar.ext']), ]) -def test_default_source_factories(fs, expected_paths, expected_slugs): +def test_default_source_factories(fs_fac, expected_paths, expected_slugs): + fs = fs_fac() fs.withConfig({ 'site': { 'sources': { @@ -131,7 +132,7 @@ app = fs.getApp() r = PageRef(app, 'whatever:doesnt_exist.md') with pytest.raises(Exception): - r.possible_rel_paths + r.possible_ref_specs def test_page_ref_with_missing_file(): @@ -139,9 +140,11 @@ with mock_fs_scope(fs): app = fs.getApp() r = PageRef(app, 'pages:doesnt_exist.%ext%') - assert r.possible_rel_paths == [ - 'doesnt_exist.html', 'doesnt_exist.md', 'doesnt_exist.textile'] - assert r.source_name == 'pages' + assert r.possible_ref_specs == [ + 'pages:doesnt_exist.html', 'pages:doesnt_exist.md', + 'pages:doesnt_exist.textile'] + with pytest.raises(PageNotFoundError): + r.source_name with pytest.raises(PageNotFoundError): r.rel_path with pytest.raises(PageNotFoundError):
--- a/tests/test_sources_posts.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_sources_posts.py Sat Jul 11 20:33:55 2015 -0700 @@ -1,44 +1,45 @@ -import os import pytest from .mockutil import mock_fs, mock_fs_scope -@pytest.mark.parametrize('fs, src_type, expected_paths, expected_metadata', [ - (mock_fs(), 'flat', [], []), - (mock_fs().withPage('test/2014-01-01_foo.md'), +@pytest.mark.parametrize('fs_fac, src_type, expected_paths, expected_metadata', [ + (lambda: mock_fs(), 'flat', [], []), + (lambda: mock_fs().withPage('test/2014-01-01_foo.md'), 'flat', ['2014-01-01_foo.md'], [(2014, 1, 1, 'foo')]), - (mock_fs(), 'shallow', [], []), - (mock_fs().withPage('test/2014/01-01_foo.md'), + (lambda: mock_fs(), 'shallow', [], []), + (lambda: mock_fs().withPage('test/2014/01-01_foo.md'), 'shallow', ['2014/01-01_foo.md'], [(2014, 1, 1, 'foo')]), - (mock_fs(), 'hierarchy', [], []), - (mock_fs().withPage('test/2014/01/01_foo.md'), + (lambda: mock_fs(), 'hierarchy', [], []), + (lambda: mock_fs().withPage('test/2014/01/01_foo.md'), 'hierarchy', ['2014/01/01_foo.md'], [(2014, 1, 1, 'foo')]), ]) -def test_post_source_factories(fs, src_type, expected_paths, expected_metadata): - fs.withConfig({ - 'site': { - 'sources': { - 'test': {'type': 'posts/%s' % src_type}}, - 'routes': [ - {'url': '/%slug%', 'source': 'test'}] - } - }) - fs.withDir('kitchen/test') - with mock_fs_scope(fs): - app = fs.getApp(cache=False) - s = app.getSource('test') - facs = list(s.buildPageFactories()) - paths = [f.rel_path for f in facs] - assert paths == expected_paths - metadata = [ - (f.metadata['year'], f.metadata['month'], - f.metadata['day'], f.metadata['slug']) - for f in facs] - assert metadata == expected_metadata +def test_post_source_factories(fs_fac, src_type, expected_paths, + expected_metadata): + fs = fs_fac() + fs.withConfig({ + 'site': { + 'sources': { + 'test': {'type': 'posts/%s' % src_type}}, + 'routes': [ + {'url': '/%slug%', 'source': 'test'}] + } + }) + fs.withDir('kitchen/test') + with mock_fs_scope(fs): + app = fs.getApp(cache=False) + s = app.getSource('test') + facs = list(s.buildPageFactories()) + paths = [f.rel_path for f in facs] + assert paths == expected_paths + metadata = [ + (f.metadata['year'], f.metadata['month'], + f.metadata['day'], f.metadata['slug']) + for f in facs] + assert metadata == expected_metadata
--- a/tests/test_templating_jinjaengine.py Thu Jun 25 05:51:47 2015 +1000 +++ b/tests/test_templating_jinjaengine.py Sat Jul 11 20:33:55 2015 -0700 @@ -37,7 +37,7 @@ def test_layout(): contents = "Blah\n" layout = "{{content}}\nFor site: {{foo}}\n" - expected = "Blah\nFor site: bar" + expected = "Blah\n\nFor site: bar" fs = (mock_fs() .withConfig(app_config) .withAsset('templates/blah.jinja', layout)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/tmpfs.py Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,82 @@ +import os +import os.path +import shutil +import random +from .basefs import TestFileSystemBase + + +class TempDirFileSystem(TestFileSystemBase): + def __init__(self, default_spec=True): + self._root = os.path.join( + os.path.dirname(__file__), + '__tmpfs__', + '%d' % random.randrange(1000)) + self._done = False + if default_spec: + self._initDefaultSpec() + + def path(self, p): + p = p.lstrip('/\\') + return os.path.join(self._root, p) + + def getStructure(self, path=None): + path = self.path(path) + if not os.path.exists(path): + raise Exception("No such path: %s" % path) + if not os.path.isdir(path): + raise Exception("Path is not a directory: %s" % path) + + res = {} + for item in os.listdir(path): + self._getStructureRecursive(res, path, item) + return res + + def getFileEntry(self, path): + path = self.path(path) + with open(path, 'r', encoding='utf8') as fp: + return fp.read() + + def _getStructureRecursive(self, target, parent, cur): + full_cur = os.path.join(parent, cur) + if os.path.isdir(full_cur): + e = {} + for item in os.listdir(full_cur): + self._getStructureRecursive(e, full_cur, item) + target[cur] = e + else: + with open(full_cur, 'r', encoding='utf8') as fp: + target[cur] = fp.read() + + def _createDir(self, path): + if not os.path.exists(path): + os.makedirs(path) + + def _createFile(self, path, contents): + dirpath = os.path.dirname(path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + with open(path, 'w', encoding='utf8') as fp: + fp.write(contents) + + if not self._done: + import traceback + with open(os.path.join(self._root, 'where.txt'), 'w') as fp: + fp.write('\n'.join(traceback.format_stack(limit=10))) + self._done = True + + +class TempDirScope(object): + def __init__(self, fs, open_patches=None): + self._fs = fs + self._open = open + + @property + def root(self): + return self._fs._root + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + shutil.rmtree(self.root) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/generate_messages.cmd Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,11 @@ +@echo off +setlocal + +set CUR_DIR=%~dp0 +set CHEF=%CUR_DIR%..\bin\chef +set OUT_DIR=%CUR_DIR%..\piecrust\resources\messages +set ROOT_DIR=%CUR_DIR%messages + +%CHEF% --root=%ROOT_DIR% bake -o %OUT_DIR% +del %OUT_DIR%\index.html +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/generate_messages.sh Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,9 @@ +#!/bin/sh + +CUR_DIR="$( cd "$( dirname "$0" )" && pwd )" +CHEF=${CUR_DIR}/../bin/chef +OUT_DIR=${CUR_DIR}/../piecrust/resources/messages +ROOT_DIR=${CUR_DIR}/messages + +$CHEF --root=$ROOT_DIR bake -o $OUT_DIR +rm ${OUT_DIR}/index.html
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/messages/config.yml Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,2 @@ +site: + title: PieCrust System Messages
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/messages/pages/_index.html Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,12 @@ +--- +title: PieCrust System Messages +--- + +Here are the **PieCrust** system message pages: + +* [Requirements Not Met]({{ pcurl('requirements') }}) +* [Error]({{ pcurl('error') }}) +* [Not Found]({{ pcurl('error404') }}) +* [Critical Error]({{ pcurl('critical') }}) + +This very page you're reading, however, is only here for convenience.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/messages/pages/critical.html Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,6 @@ +--- +title: The Whole Kitchen Burned Down! +layout: error +--- +Something critically bad happened, and **PieCrust** needs to shut down. It's probably our fault. +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/messages/pages/error.html Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,5 @@ +--- +title: The Cake Just Burned! +layout: error +--- +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/messages/pages/error404.html Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,6 @@ +--- +title: Can't find the sugar! +layout: error +--- +It looks like the page you were trying to access does not exist around here. Try going somewhere else. +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/messages/templates/default.html Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,70 @@ +<!doctype html> +<html> +<head> + <title>{{ page.title }}</title> + <meta name="generator" content="PieCrust" /> + <link rel="stylesheet" type="text/css" href="http://fonts.googleapis.com/css?family=Lobster"> + <style> + body { + margin: 0; + padding: 1em; + background: #eee; + color: #000; + font-family: Georgia, serif; + } + h1 { + font-size: 4.5em; + font-family: Lobster, 'Trebuchet MS', Verdana, sans-serif; + text-align: center; + font-weight: bold; + margin-top: 0; + color: #333; + text-shadow: 0px 2px 5px rgba(0,0,0,0.3); + } + h2 { + font-size: 2.5em; + font-family: 'Lobster', 'Trebuchet MS', Verdana, sans-serif; + } + code { + background: #ddd; + padding: 0 0.2em; + } + #preamble { + font-size: 1.2em; + font-style: italic; + text-align: center; + margin-bottom: 0; + } + #container { + margin: 0 20%; + } + #content { + margin: 2em 1em; + } + .error-details { + color: #d11; + } + .note { + margin: 3em; + color: #888; + font-style: italic; + } + </style> +</head> +<body> + <div id="container"> + <div id="header"> + <p id="preamble">A Message From The Kitchen:</p> + <h1>{{ page.title }}</h1> + </div> + <hr /> + <div id="content"> + {% block content %} + {{ content|safe }} + {% endblock %} + </div> + <hr /> + {% block footer %}{% endblock %} + </div> +</body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/util/build/messages/templates/error.html Sat Jul 11 20:33:55 2015 -0700 @@ -0,0 +1,27 @@ +{% extends "default.html" %} + +{% block content %} +{{content|safe}} + +{# The following is `raw` because we want it to be in the + produced page, so it can then be templated on the fly + with the error messages #} +{% raw %} +{% if details %} +<div class="error-details"> + <p>Error details:</p> + <ul> + {% for desc in details %} + <li>{{ desc }}</li> + {% endfor %} + </ul> +</div> +{% endif %} +{% endraw %} +{% endblock %} + +{% block footer %} +{% pcformat 'textile' %} +p(note). You're seeing this because something wrong happend. To see detailed errors with callstacks, run chef with the @--debug@ parameter, append @?!debug@ to the URL, or initialize the @PieCrust@ object with @{'debug': true}@. On the other hand, to see you custom error pages, set the @site/display_errors@ setting to @false@. +{% endpcformat %} +{% endblock %}