Source code for engineer.models

# 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