# coding=utf-8
import logging
import re
from codecs import open
from copy import copy
from datetime import datetime, timedelta
import markdown
import times
import yaml
from brownie.caching import cached_property
from dateutil import parser
from path import path
from propane.datastructures import CaseInsensitiveDict
from typogrify.filters import typogrify
from yaml.scanner import ScannerError
from engineer.conf import settings
from engineer.enums import Status
from engineer.exceptions import PostMetadataError
from engineer.filters import localtime
from engineer.plugins import PostProcessor
from engineer.util import setonce, slugify, chunk, urljoin, wrap_list
try:
import cPickle as pickle
except ImportError:
import pickle
__author__ = 'Tyler Butler <tyler@tylerbutler.com>'
logger = logging.getLogger(__name__)
[docs]class Post(object):
"""
Represents a post written in Markdown and stored in a file.
:param source: path to the source file for the post.
"""
_regex = re.compile(
r'^[\n|\r\n]*(?P<fence>---)?[\n|\r\n]*(?P<metadata>.+?)[\n|\r\n]*---[\n|\r\n]*(?P<content>.*)[\n|\r\n]*',
re.DOTALL)
# Make _content_raw only settable once. This is just to help prevent data loss that might be caused by
# inadvertantly messing with this property.
_content_raw = setonce()
_file_contents_raw = setonce()
@staticmethod
def convert_to_html(content):
return typogrify(markdown.markdown(content, extensions=['extra', 'codehilite']))
def __init__(self, source):
self.source = path(source).abspath()
"""The absolute path to the source file for the post."""
self.html_template_path = 'theme/post_detail.html'
"""The path to the template to use to transform the post into HTML."""
self.markdown_template_path = 'core/post.md'
"""The path to the template to use to transform the post back into a :ref:`post source file <posts>`."""
# This will get set to `True in _parse_source if the source file has 'fenced metadata' (like Jekyll)
self._fence = False
metadata, self._content_raw = self._parse_source()
if not hasattr(self, 'content_preprocessed'):
self.content_preprocessed = self.content_raw
self._content_finalized = self.content_raw
# Handle any preprocessor plugins
for plugin in PostProcessor.plugins:
plugin.preprocess(self, metadata)
self.title = metadata.pop('title', self.source.namebase.replace('-', ' ').replace('_', ' ').title())
"""The title of the post."""
self.slug = metadata.pop('slug', slugify(self.title))
"""The slug for the post."""
self._tags = wrap_list(metadata.pop('tags', []))
self.link = metadata.pop('link', None)
"""The post's :ref:`external link <post link>`."""
self.via = metadata.pop('via', None)
"""The post's attribution name."""
self.via_link = metadata.pop('via-link', metadata.pop('via_link', None))
"""The post's attribution link."""
try:
self.status = Status(metadata.pop('status', Status.draft.name))
"""The status of the post (published or draft)."""
except ValueError:
logger.warning("'%s': Invalid status value in metadata. Defaulting to 'draft'." % self.title)
self.status = Status.draft
self.timestamp = metadata.pop('timestamp', None)
"""The date/time the post was published or written."""
if self.timestamp is None:
self.timestamp = times.now()
utctime = True
# Reduce resolution of timestamp
delta = timedelta(seconds=self.timestamp.second)
self.timestamp = self.timestamp - delta
else:
utctime = False
if not isinstance(self.timestamp, datetime):
# looks like the timestamp from YAML wasn't directly convertible to a datetime, so we need to parse it
self.timestamp = parser.parse(str(self.timestamp))
if self.timestamp.tzinfo is not None:
# parsed timestamp has an associated timezone, so convert it to UTC
self.timestamp = times.to_universal(self.timestamp)
elif not utctime:
# convert to UTC assuming input time is in the DEFAULT_TIMEZONE
self.timestamp = times.to_universal(self.timestamp, settings.POST_TIMEZONE)
self.content = Post.convert_to_html(self.content_preprocessed)
"""The post's content in HTML format."""
# determine the URL based on the HOME_URL and the PERMALINK_STYLE settings
permalink = settings.PERMALINK_STYLE.format(year=unicode(self.timestamp_local.year),
month=u'{0:02d}'.format(self.timestamp_local.month),
day=u'{0:02d}'.format(self.timestamp_local.day),
i_month=self.timestamp_local.month,
i_day=self.timestamp_local.day,
title=self.slug, # for Jekyll compatibility
slug=self.slug,
timestamp=self.timestamp_local,
post=self)
if permalink.endswith('index.html'):
permalink = permalink[:-10]
elif permalink.endswith('.html') or permalink.endswith('/'):
pass
else:
permalink += '.html'
self._permalink = permalink
# keep track of any remaining properties in the post metadata
metadata.pop('url', None) # remove the url property from the metadata dict before copy
self.custom_properties = copy(metadata)
"""A dict of any custom metadata properties specified in the post."""
# handle any postprocessor plugins
for plugin in PostProcessor.plugins:
plugin.postprocess(self)
# update cache
settings.POST_CACHE[self.source] = self
@cached_property
def url(self):
"""The site-relative URL to the post."""
url = u'{home_url}{permalink}'.format(home_url=settings.HOME_URL,
permalink=self._permalink)
url = re.sub(r'/{2,}', r'/', url)
return url
@cached_property
def absolute_url(self):
"""The absolute URL to the post."""
return u'{0}{1}'.format(settings.SITE_URL, self.url)
@cached_property
def output_path(self):
url = self._permalink
if url.endswith('/'):
url += 'index.html'
return path(settings.OUTPUT_CACHE_DIR / url)
@cached_property
def output_file_name(self):
r = self.output_path.name
return r
@cached_property
def tags(self):
"""A list of strings representing the tags applied to the post."""
r = [unicode(t) for t in self._tags]
return r
@property
def content_finalized(self):
return self._content_finalized
@property
def content_raw(self):
return self._content_raw
@property
def is_draft(self):
"""``True`` if the post is a draft, ``False`` otherwise."""
return self.status == Status.draft
@property
def is_published(self):
"""``True`` if the post is published, ``False`` otherwise."""
return self.status == Status.published and self.timestamp <= times.now()
@property
def is_pending(self):
"""``True`` if the post is marked as published but has a timestamp set in the future."""
return self.status == Status.published and self.timestamp >= times.now()
@property
def is_external_link(self):
"""``True`` if the post has an associated external link. ``False`` otherwise."""
return self.link is not None and self.link != ''
@property
def timestamp_local(self):
"""
The post's :attr:`timestamp` in 'local' time.
Local time is determined by the :attr:`~engineer.conf.EngineerConfiguration.POST_TIMEZONE` setting.
"""
return localtime(self.timestamp)
def _parse_source(self):
try:
with open(self.source, mode='r') as the_file:
item = unicode(the_file.read())
except UnicodeDecodeError:
with open(self.source, mode='r', encoding='UTF-8') as the_file:
item = the_file.read()
self._file_contents_raw = item
parsed_content = re.match(self._regex, item)
if parsed_content is None or parsed_content.group('metadata') is None:
# Parsing failed, maybe there's no metadata
raise PostMetadataError()
if parsed_content.group('fence') is not None:
self._fence = True
# 'Clean' the YAML section since there might be tab characters
metadata = parsed_content.group('metadata').replace('\t', ' ')
try:
metadata = yaml.load(metadata)
except ScannerError:
raise PostMetadataError("YAML error parsing metadata.")
if not isinstance(metadata, dict):
raise PostMetadataError("Metadata isn't a dict. Instead, it's a %s." % type(metadata))
# Make the metadata dict case insensitive
metadata = CaseInsensitiveDict(metadata)
content = parsed_content.group('content')
return metadata, content
[docs] def render_html(self, all_posts=None):
"""
Renders the Post as HTML using the template specified in :attr:`html_template_path`.
:param all_posts: An optional :class:`PostCollection` containing all of the posts in the site.
:return: The rendered HTML as a string.
"""
index = all_posts.index(self)
if index > 0: # has newer posts
newer_post = all_posts[index - 1]
else:
newer_post = None
if index < len(all_posts) - 1: # has older posts
older_post = all_posts[index + 1]
else:
older_post = None
return settings.JINJA_ENV.get_template(self.html_template_path).render(post=self,
newer_post=newer_post,
older_post=older_post,
all_posts=all_posts,
nav_context='post')
[docs] def set_finalized_content(self, content, caller_class):
"""
Plugins can call this method to modify post content that is written back to source post files.
This method can be called at any time by anyone, but it has no effect if the caller is not granted the
``MODIFY_RAW_POST`` permission in the Engineer configuration.
The :attr:`~engineer.conf.EngineerConfiguration.FINALIZE_METADATA` setting must also be enabled in order for
calls to this method to have any effect.
:param content: The modified post content that should be written back to the post source file.
:param caller_class: The class of the plugin that's calling this method.
:return: ``True`` if the content was successfully modified; otherwise ``False``.
"""
caller = caller_class.get_name() if hasattr(caller_class, 'get_name') else unicode(caller_class)
if not settings.FINALIZE_METADATA:
logger.warning("A plugin is trying to modify the post content but the FINALIZE_METADATA setting is "
"disabled. This setting must be enabled for plugins to modify post content. "
"Plugin: %s" % caller)
return False
perms = settings.PLUGIN_PERMISSIONS['MODIFY_RAW_POST']
if caller not in perms and '*' not in perms:
logger.warning("A plugin is trying to modify the post content but does not have the "
"MODIFY_RAW_POST permission. Plugin: %s" % caller)
return False
else:
logger.debug("%s is setting post source content." % caller)
self._content_finalized = content
return True
def __unicode__(self):
return self.slug
__repr__ = __unicode__
[docs]class PostCollection(list):
"""A collection of :class:`Posts <engineer.models.Post>`."""
#noinspection PyTypeChecker
def __init__(self, seq=()):
list.__init__(self, seq)
self.listpage_template = settings.JINJA_ENV.get_template('theme/post_list.html')
self.archive_template = settings.JINJA_ENV.get_template('theme/post_archives.html')
def paginate(self, paginate_by=None):
if paginate_by is None:
paginate_by = settings.ROLLUP_PAGE_SIZE
return chunk(self, paginate_by, PostCollection)
@cached_property
def published(self):
"""Returns a new PostCollection containing the subset of posts that are published."""
return PostCollection([p for p in self if p.is_published is True])
@cached_property
def drafts(self):
"""Returns a new PostCollection containing the subset of posts that are drafts."""
return PostCollection([p for p in self if p.is_draft is True])
@property
def pending(self):
"""Returns a new PostCollection containing the subset of posts that are pending."""
return PostCollection([p for p in self if p.is_pending is True])
@cached_property
def review(self):
"""Returns a new PostCollection containing the subset of posts whose status is :attr:`~Status.review`."""
return PostCollection([p for p in self if p.status == Status.review])
@cached_property
def all_tags(self):
"""Returns a list of all the unique tags, as strings, that posts in the collection have."""
tags = set()
for post in self:
tags.update(post.tags)
return list(tags)
[docs] def tagged(self, tag):
"""Returns a new PostCollection containing the subset of posts that are tagged with *tag*."""
return PostCollection([p for p in self if unicode(tag) in p.tags])
@staticmethod
def output_path(slice_num):
return path(settings.OUTPUT_CACHE_DIR / ("page/%s/index.html" % slice_num))
def render_listpage_html(self, slice_num, has_next, has_previous, all_posts=None):
return self.listpage_template.render(
post_list=self,
slice_num=slice_num,
has_next=has_next,
has_previous=has_previous,
all_posts=all_posts,
nav_context='listpage')
def render_archive_html(self, all_posts=None):
return self.archive_template.render(post_list=self,
all_posts=all_posts,
nav_context='archive')
def render_tag_html(self, tag, all_posts=None):
return settings.JINJA_ENV.get_template('theme/tags_list.html').render(tag=tag,
post_list=self.tagged(tag),
all_posts=all_posts,
nav_context='tag')
class TemplatePage(object):
def __init__(self, template_path):
self.html_template = settings.JINJA_ENV.get_template(
str(settings.TEMPLATE_DIR.relpathto(template_path)).replace('\\', '/'))
namebase = template_path.namebase
name_components = settings.TEMPLATE_PAGE_DIR.relpathto(template_path).splitall()[1:]
name_components[-1] = namebase
self.name = '/'.join(name_components)
self.absolute_url = urljoin(settings.HOME_URL, self.name)
self.output_path = path(settings.OUTPUT_CACHE_DIR / self.name)
self.output_file_name = 'index.html'
settings.URLS[self.name] = self.absolute_url
def render_html(self, all_posts=None):
rendered = self.html_template.render(nav_context=self.name,
all_posts=all_posts)
return rendered