# coding=utf-8
from inspect import isfunction
import logging
import platform
import shelve
from datetime import datetime
from appdirs import user_cache_dir, user_data_dir
from jinja2.loaders import ChoiceLoader
import pytz
import times
import yaml
from jinja2 import Environment, FileSystemLoader, FileSystemBytecodeCache
# noinspection PyPackageRequirements
from path import path
from brownie.caching import cached_property
from engineer.cache import SimpleFileCache
from engineer.plugins import get_all_plugin_types, JinjaEnvironmentPlugin
from engineer.util import urljoin, slugify, ensure_exists, wrap_list, update_additive, make_precompiled_reference
from engineer import version
__author__ = 'Tyler Butler <tyler@tylerbutler.com>'
logger = logging.getLogger(__name__)
permalink_styles = {
'slug': '{year}/{month}/{day}/{title}.html',
'pretty': '{year}/{month}/{title}/',
'fulldate': '{year}/{month}/{day}/{title}/'
}
deprecated_settings = (
# ('SETTING_NAME', version_deprecated, 'Message.')
('NORMALIZE_INPUT_FILES', 0.4, 'This setting is now ignored.'),
('NORMALIZE_INPUT_FILE_MASK', 0.4, 'This setting is now ignored.'),
('JINJA_CACHE_DIR', 0.5, 'This setting is now ignored.')
)
class SettingsFileNotFoundException(Exception):
pass
# noinspection PyPep8Naming
[docs]class EngineerConfiguration(object):
"""
Stores all of the configuration settings for a given Engineer site.
This class uses the Borg design pattern and shares state among all
instances of the class.
There seem to be a lot of differing opinions about whether this design
pattern is A Good Idea (tm) or not. It definitely seems better than
Singletons since it enforces *behavior*, not *structure*,
but it's also possible there's a better way to do it in Python with
judicious use of globals.
"""
_state = {}
def __new__(cls, *p, **k):
self = object.__new__(cls)
self.__dict__ = cls._state
return self
_required_params = ('SITE_URL',)
class _EngineerConstants(object):
# ENGINEER 'CONSTANTS'
ENGINEER_APP_WIDE_SETTINGS_DIR = ensure_exists(user_data_dir('Engineer', 'Engineer'))
ROOT_DIR = path(__file__).dirname().abspath()
TEMPLATE_DIR = (ROOT_DIR / 'templates').abspath()
STATIC_DIR = (ROOT_DIR / 'static').abspath()
THEMES_DIR = (ROOT_DIR / 'themes').abspath()
LIB_DIR = (STATIC_DIR / 'engineer/lib/').abspath()
JINJA_CACHE_DIR = ensure_exists(path(user_cache_dir('Engineer', 'Engineer')) / '_jinja_cache')
FOUNDATION_CSS = 'foundation'
JQUERY = 'jquery-1.11.0.min.js'
LESS_JS = 'less-1.7.0.min.js'
MODERNIZR = 'modernizr-2.7.1.min.js'
NORMALIZE_CSS = 'normalize/normalize.css'
# URLs to included libraries - will be updated in the EngineerConfiguration.initialize() method.
FOUNDATION_CSS_URL = None
JQUERY_URL = None
LESS_JS_URL = None
MODERNIZR_URL = None
NORMALIZE_CSS_URL = None
def __init__(self, settings_file=None):
self.reload(settings_file)
self.COMPRESS_FILE_LIST = set()
def reload(self, settings_file=None):
if settings_file is None:
if hasattr(self, 'SETTINGS_FILE') and self.SETTINGS_FILE is not None:
# First check if SETTINGS_FILE has been defined. If so, we'll reload from that file.
settings_file = self.SETTINGS_FILE
else:
# Looks like we're just loading the 'empty' config.
logger.info("Initializing empty configuration.")
self.SETTINGS_FILE = None
self._initialize({})
return
if path(settings_file).exists() and path(settings_file).isfile():
self.SETTINGS_FILE = settings_file = path(settings_file).expand().abspath()
logger.console("Loading configuration from %s." % settings_file)
# Find the complete set of settings files based on inheritance
all_configs = []
config = {}
try:
while True:
with open(settings_file, mode='rb') as the_file:
temp_config = yaml.load(the_file)
logger.info("Loaded %s file." % settings_file)
all_configs.append((temp_config, settings_file))
if 'SUPER' not in temp_config:
break
else:
new_settings = path(temp_config['SUPER']).expand()
if not new_settings.isabs():
settings_file = (settings_file.dirname() / new_settings).abspath()
else:
settings_file = new_settings.abspath()
logger.debug("Going to next settings file... %s" % settings_file)
except Exception as e:
logger.exception(e.message)
# load parent configs
all_configs.reverse()
for c in all_configs[:-1]:
logger.debug("Loading parent configuration from %s." % path(c[1]).abspath())
update_additive(config, c[0])
# load main config
logger.debug("Finalizing configuration from %s." % path(all_configs[-1][1]).abspath())
update_additive(config, all_configs[-1][0])
for param in self._required_params:
if param not in config:
raise Exception("Required setting '%s' is missing from config file %s." % (param,
self.SETTINGS_FILE))
self._initialize(config)
self.SETTINGS_FILE_LOAD_TIME = times.now()
else:
raise SettingsFileNotFoundException("Settings file %s not found!" % settings_file)
def _initialize(self, config):
self._check_deprecated_settings(config)
self.ENGINEER = EngineerConfiguration._EngineerConstants()
# CONTENT DIRECTORIES
self.SETTINGS_DIR = path(config.pop('SETTINGS_DIR',
self.SETTINGS_FILE.dirname().abspath() if self.SETTINGS_FILE is not None
else path.getcwd()))
self.CONTENT_DIR = self.normalize(config.pop('CONTENT_DIR', 'content'))
self.POST_DIR = self.normalize_list(config.pop('POST_DIR', 'posts'))
self.OUTPUT_DIR = self.normalize(config.pop('OUTPUT_DIR', 'output'))
self.OUTPUT_DIR_IGNORE = wrap_list(config.pop('OUTPUT_DIR_IGNORE', ['.git', '.gitignore']))
self.TEMPLATE_DIR = self.normalize(config.pop('TEMPLATE_DIR', 'templates'))
self.TEMPLATE_PAGE_DIR = self.normalize(
config.pop('TEMPLATE_PAGE_DIR', (self.TEMPLATE_DIR / 'pages').abspath())
)
self.LOG_DIR = self.normalize(config.pop('LOG_DIR', 'logs'))
if self.SETTINGS_FILE is None:
self.LOG_FILE = self.normalize(config.pop('LOG_FILE', (self.LOG_DIR / 'build.log').abspath()))
else:
self.LOG_FILE = self.normalize(
config.pop(
'LOG_FILE',
(self.LOG_DIR / ('%s-%s.log' % (datetime.now().strftime('%m.%d_%H.%M.%S'),
self.SETTINGS_FILE.name))).abspath())
)
self.CACHE_DIR = self.normalize(config.pop('CACHE_DIR', None))
if self.CACHE_DIR is None:
if self.SETTINGS_FILE is not None:
self.CACHE_DIR = self.normalize('_cache/%s' % self.SETTINGS_FILE.name)
else:
self.CACHE_DIR = self.normalize('_cache/None')
else:
self.CACHE_DIR = self.normalize(self.CACHE_DIR)
self.CACHE_FILE = self.normalize(
config.pop('CACHE_FILE', (self.CACHE_DIR / 'engineer.cache').abspath())
)
self.OUTPUT_CACHE_DIR = self.normalize(
config.pop('OUTPUT_CACHE_DIR', (self.CACHE_DIR / 'output_cache').abspath())
)
self.JINJA_CACHE_DIR = self.normalize(
config.pop('JINJA_CACHE_DIR', (self.CACHE_DIR / 'jinja_cache').abspath())
)
self.BUILD_STATS_FILE = self.normalize(
config.pop('BUILD_STATS_FILE', (self.CACHE_DIR / 'build_stats.cache').abspath())
)
# PLUGINS
self.PLUGINS = self.normalize_list(config.pop('PLUGINS', None))
if self.PLUGINS is not None:
for plugin in self.PLUGINS:
__import__(plugin)
# THEMES
self.THEME_DIRS = self.normalize_list(config.pop('THEME_DIRS', None))
self.THEME_FINDERS = [
'engineer.finders.ThemeDirsFinder',
'engineer.finders.SiteFinder',
'engineer.finders.PluginFinder',
'engineer.finders.DefaultFinder'
]
self.THEME_SETTINGS = config.pop('THEME_SETTINGS', {})
self.THEME = config.pop('THEME', 'dark_rainbow')
# PREPROCESSOR / COMPRESSOR SETTINGS
self.COMPRESSOR_ENABLED = config.pop('COMPRESSOR_ENABLED', True)
self.COMPRESSOR_FILE_EXTENSIONS = config.pop('COMPRESSOR_FILE_EXTENSIONS', ['js', 'css'])
self.PREPROCESS_LESS = config.pop('PREPROCESS_LESS', True)
if not 'LESS_PREPROCESSOR' in config:
if platform.system() == 'Windows':
self.LESS_PREPROCESSOR = str(self.ENGINEER.ROOT_DIR /
'lib/less.js-windows/lessc.cmd') + ' {infile} {outfile}'
else:
self.LESS_PREPROCESSOR = 'lessc {infile} {outfile}'
else:
self.LESS_PREPROCESSOR = path(config.pop('LESS_PREPROCESSOR'))
# SITE SETTINGS
self.SITE_TITLE = config.pop('SITE_TITLE', 'SITE_TITLE')
self.SITE_URL = config.pop('SITE_URL', 'SITE_URL')
self.SITE_AUTHOR = config.pop('SITE_AUTHOR', None)
self.HOME_URL = config.pop('HOME_URL', '/')
# HOME_URL must end with a slash
if not self.HOME_URL.endswith('/'):
self.HOME_URL += '/'
self.STATIC_URL = config.pop('STATIC_URL', urljoin(self.HOME_URL, 'static'))
# starting in version 0.5, the default permalink style will change to 'pretty'
permalink_setting = config.pop('PERMALINK_STYLE', None)
if permalink_setting is None:
self.PERMALINK_STYLE = permalink_styles['pretty']
else:
self.PERMALINK_STYLE = permalink_styles.get(permalink_setting, permalink_setting)
self.ROLLUP_PAGE_SIZE = int(config.pop('ROLLUP_PAGE_SIZE', 5))
# RSS FEED SETTINGS
self.FEED_TITLE = config.pop('FEED_TITLE', self.SITE_TITLE + ' Feed')
self.FEED_ITEM_LIMIT = config.pop('FEED_ITEM_LIMIT', self.ROLLUP_PAGE_SIZE)
self.FEED_DESCRIPTION = config.pop('FEED_DESCRIPTION',
'The %s most recent posts from %s.' % (self.FEED_ITEM_LIMIT, self.SITE_URL))
self.FEED_URL = config.pop('FEED_URL', urljoin(self.HOME_URL, 'feeds/atom.xml'))
# These 'constants' are updated here so they're relative to the STATIC_URL value
lib_path = urljoin(self.STATIC_URL, 'engineer/lib')
self.ENGINEER.FOUNDATION_CSS_URL = urljoin(lib_path, 'foundation/')
self.ENGINEER.JQUERY_URL = urljoin(lib_path, self.ENGINEER.JQUERY)
self.ENGINEER.LESS_JS_URL = urljoin(lib_path, self.ENGINEER.LESS_JS)
self.ENGINEER.MODERNIZR_URL = urljoin(lib_path, self.ENGINEER.MODERNIZR)
self.ENGINEER.NORMALIZE_CSS_URL = urljoin(lib_path, self.ENGINEER.NORMALIZE_CSS)
# URL helper functions
def page(num):
page_path = urljoin('page', str(num))
return urljoin(self.HOME_URL, page_path)
def tag(name):
page_path = urljoin('tag', slugify(name))
page_path = urljoin(self.HOME_URL, page_path)
return page_path
self.URLS = {
'home': self.HOME_URL,
'archives': urljoin(self.HOME_URL, 'archives'),
'feed': self.FEED_URL,
'listpage': page,
'tag': tag,
}
# Update URLs from the config setting if they're present
self.URLS.update(config.pop('URLS', {}))
# MISCELLANEOUS SETTINGS
self.ACTIVE_NAV_CLASS = config.pop('ACTIVE_NAV_CLASS', 'current')
self.DEBUG = config.pop('DEBUG', False)
#self.DISABLE_CACHE = config.pop('DISABLE_CACHE', False)
self.PLUGIN_PERMISSIONS = {
'MODIFY_RAW_POST': []
}
provided_permissions = config.pop('PLUGIN_PERMISSIONS', {})
update_additive(self.PLUGIN_PERMISSIONS, provided_permissions)
self.PUBLISH_DRAFTS = config.pop('PUBLISH_DRAFTS', False)
self.PUBLISH_PENDING = config.pop('PUBLISH_PENDING', False)
self.PUBLISH_REVIEW = config.pop('PUBLISH_REVIEW', False)
self.POST_TIMEZONE = pytz.timezone(config.pop('POST_TIMEZONE', 'UTC'))
self.SERVER_TIMEZONE = self.POST_TIMEZONE if config.get('SERVER_TIMEZONE',
None) is None else config.pop('SERVER_TIMEZONE')
self.TIME_FORMAT = config.pop('TIME_FORMAT', '%I:%M %p %A, %B %d, %Y %Z') # '%Y-%m-%d %H:%M:%S %Z%z'
# Let plugins deal with their settings in their own way if needed
for plugin_type in get_all_plugin_types():
for plugin in plugin_type.plugins:
logger.debug("Calling handle_settings on plugin: %s. config dict is: %s" % (plugin, config))
config = plugin.handle_settings(config, self)
# Pull any remaining settings in the config and set them as attributes on the settings object
for k, v in config.iteritems():
setattr(self, k, v)
@staticmethod
def _check_deprecated_settings(config):
for setting in deprecated_settings:
if config.pop(setting[0], None) is not None:
logger.warning("The '%s' setting was deprecated in version %s: %s" % setting)
@cached_property
def OUTPUT_STATIC_DIR(self):
return path(self.OUTPUT_CACHE_DIR / self.ENGINEER.STATIC_DIR.basename()).abspath()
@cached_property
def JINJA_ENV(self):
from engineer.processors import preprocess_less
from engineer.themes import ThemeManager
# Configure Jinja2 environment
logger.debug("Configuring the Jinja environment.")
# Helper function to look up a URL by name
def urlname(name, *args):
url = settings.URLS.get(name, settings.HOME_URL)
if isfunction(url):
return url(*args)
else:
return url
env = Environment(
loader=ChoiceLoader(
[FileSystemLoader([self.TEMPLATE_DIR]),
ThemeManager.current_theme().template_loader,
#self.ENGINEER.THEMES_DIR / 'base_templates',
FileSystemLoader([self.ENGINEER.TEMPLATE_DIR])]
),
extensions=['jinja2.ext.with_', ],
bytecode_cache=FileSystemBytecodeCache(directory=self.ENGINEER.JINJA_CACHE_DIR),
trim_blocks=True)
# JinjaEnvironment plugins
for plugin in JinjaEnvironmentPlugin.plugins:
plugin.update_environment(env)
# Built-in globals
env.globals['theme'] = ThemeManager.current_theme()
env.globals['urlname'] = urlname
env.globals['preprocess_less'] = preprocess_less
env.globals['make_precompiled_reference'] = make_precompiled_reference
# env.globals['url'] = url
env.globals['STATIC_URL'] = self.STATIC_URL
env.globals['DEBUG'] = self.DEBUG
env.globals['settings'] = self
return env
@cached_property
def CACHE(self):
# This check is a hack to ensure sphinx autodoc doesn't choke on this property.
# I don't know why it chokes here, but I think the exception handling might be
# messing with it.
if self is None:
return
# Use a shelf as the main cache
try:
CACHE = shelve.open(self.CACHE_FILE, writeback=True)
except Exception as e:
logger.exception(e)
CACHE = None
exit()
if CACHE is None or len(CACHE) == 0 or 'version' not in CACHE or CACHE['version'] != version:
# all new caches
logger.warning("Caches either don't exist or are old, so creating new ones...")
CACHE.clear()
CACHE['version'] = version
return CACHE
@cached_property
def COMPRESSION_CACHE(self):
if 'COMPRESSION_CACHE' not in self.CACHE:
self.CACHE['COMPRESSION_CACHE'] = SimpleFileCache(version=version)
return self.CACHE['COMPRESSION_CACHE']
@cached_property
def POST_CACHE(self):
if 'POST_CACHE' not in self.CACHE:
self.CACHE['POST_CACHE'] = SimpleFileCache(version=version)
return self.CACHE['POST_CACHE']
@cached_property
def LESS_CACHE(self):
if 'LESS_CACHE' not in self.CACHE:
self.CACHE['LESS_CACHE'] = SimpleFileCache(version=version)
return self.CACHE['LESS_CACHE']
def normalize(self, p):
if p is None:
return None
the_path = path(p).expand()
if the_path.isabs():
return the_path
else:
return (self.SETTINGS_DIR / the_path).abspath()
def normalize_list(self, p):
l = wrap_list(p)
return_list = [self.normalize(p) for p in l]
return return_list
[docs] def create_required_directories(self):
"""Creates any directories required for Engineer to function if they don't already exist."""
required = (self.CACHE_DIR,
self.LOG_DIR,
self.OUTPUT_DIR,
self.ENGINEER.JINJA_CACHE_DIR,)
for folder in required:
ensure_exists(folder, assume_dirs=True)
settings = EngineerConfiguration()