Source code for editfrontmatter.EditFrontMatter

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Edit Front Matter Module
==============================

.. module:: editfrontmatter

:Synopsis: A thread safe class that uses Jinja2 templating to edit yaml front
    matter within text files. This class is intended to be used for batch processing.

:Platform: Unix, Windows, |python_version|

:Dependencies:

    .. literalinclude:: ../../../requirements.txt
        :language: text

:License: `MIT <https://karlredman.github.io/EditFrontMatter/LICENSE>`_

.. :moduleauthor: `Karl N. Redman <https://karlredman.github.io>`_

:Module Author: `Karl N. Redman <https://karlredman.github.io>`_

:Current Release:
    version: |release|

.. versionadded:: 0.0.1
    Initial Version

"""

# Imports
import re
import traceback
import oyaml as yaml    # preserve yaml dict order
import jinja2


[docs]class EditFrontMatter_Exception(Exception): """Custom exception handler for EditFrontMatter Project"""
[docs] def __init__(self, msg, exc, *args, **kwargs): """ :Description: A custom exception handler for the module. Provides a simplified output message for debugging. Args: msg (str): A custom messsage for the exception caught in the code exc (Exception): Original exception object from try block Returns: Exception obj """ msg += "\n {exc}".format(exc=str(exc)) template = '\n Error @ {filename}, Line {linenum} in {funcname}:\n >>>> {source}' for tb_info in traceback.extract_tb(exc.__traceback__): filename, linenum, funcname, source = tb_info if funcname != '<module>': funcname = funcname + '()' msg += template.format( filename=filename, linenum=linenum, source=source, funcname=funcname) # actual traceback # tbe = ''.join(traceback.TracebackException(exc.__class__, exc, exc.__traceback__).format()) msg = "EditFrontMatter_Exception: {msg}".format(msg=msg) super().__init__(msg)
[docs]class EditFrontMatter(object): """Main Class for module"""
[docs] def __init__( self, *, file_path=None, jinja2_env=jinja2.Environment(loader=None), template_str="", yaml_delim='---', keys_toDelete=[], do_readFile=True ): """Main class for the module. Programmatically Adds / Updates / Deletes yaml \ front matter elements embedded in text/markdown files. Hint: This class uses keyword only arguments. Inheriting this class would look something like the following:: class Derived_EditFrontMatter (EditFrontMatter): def __init__(self,**kwargs): EditFrontMatter.__init__(self, **kwargs) Args: yaml_delim (str): yaml file section delimiter (i.e. "---")used to locate the source file's embedded yaml section. do_readFile (bool): allow instantiation without implicit :func:`readFile` call Attributes: self.file_lines (list): Lines from the data source file. Must be managed after creating the class object if `__init__(do_readFile=False)` self.file_path (str): Path of source data file. Superfluous if :func:`readFile` is never called self.fmatter (yaml): [default: empty :class:`dict` if yaml not found] Front matter as a yaml object. Set in :func:`readFile`. self.yaml_delim (str): [default:"---"] Front matter delimiter. Can be used to change front matter delimter between reading the source file into :attr:`file_lines` and executing :func:`dumpFrontMatter`/:func:`writeFile`. self.yamlSeperator_pattern (:func:`re.compile`): Regex patten for the yaml line delimiter. only used if :func:`readFile` is called self.yaml_start (int): Beginning of the yaml blob in the original source file. Set in :func:`readFile` self.yaml_end (int): End of the yaml blob in the origional source file. Set in :func:`readFile` self.template_str (str): Contains the jinja2 template. Can be manipulated between class instantiation and executing :func:`run` self.jinja2_env (jinja2.Environment): This object can be specified during class instantiation if greater control is required self.keys_toDelete (list): keys to be deleted from :attr:`fmatter` object. Utilized at the end of the :func:`run` method Throws: :class:`EditFrontMatter_Exception` """ # critical defaults self.yaml_start = None self.yaml_end = None self.file_empty = True # file info self.file_path = file_path # yaml processing self.yaml_delim = yaml_delim self.yamlSeperator_pattern = re.compile("^" + self.yaml_delim + ".*") # jinja2 self.template_str = template_str self.jinja2_env = jinja2_env self.keys_toDelete = keys_toDelete # possibly postpone reading the file if do_readFile: self.readFile()
[docs] def set_yaml_delim(self, delim, *args, **kwargs) -> None: """ Set the yaml delimiter and compile it. Args: delim (str): A string to use as a delimiter for finding and editing frontmatter in a file. """ self.yaml_delim = delim self.yamlSeperator_pattern = re.compile("^" + self.yaml_delim + ".*")
[docs] def readFile(self, file_path=None, *args, **kwargs) -> None: """ Read a file into :attr:`file_lines` list (if applicable) and seperate the front matter into :attr:`fmatter` yaml object. This function resets :attr:`fmatter`. Args: file_path (str): optional file path Hint: If *local* `file_path`:`None` and :attr:`file_path`:`None`, :attr:`file_lines` should be populated before calling this function. In the example below, the initialization would fail if `do_readFile:True`:: proc = EditFrontMatter(do_readFile=False) proc.file_lines = ''.join(open(RUN_PATH + "example.md", "r").readlines()) # initialize proc.fmatter and record data position proc.readFile() ... Note: If the file source content is empty :attr:`file_empty` is set to `True`. This affects :func:`dumpFileData` and :func:`writeFile` behavior """ if file_path is None: file_path = self.file_path else: self.file_path = file_path # if no file path, user is managing `file_lines` outside of the class if self.file_path is not None: # with open(file_path, "r") as fo: # self.file_lines = fo.readlines() try: with open(file_path, "r") as fo: self.file_lines = fo.readlines() except IOError as e: import sys t, v, tb = sys.exc_info() raise EditFrontMatter_Exception("self.file_path: {file_path}". format(file_path=self.file_path), e) from e # yaml data re-init / paranoia self.yaml_start = None self.yaml_end = None self.fmatter = None self.file_empty = True yaml_lines = [] line_num = 0 # find the beg. and end separators # populate yaml_lines for line in self.file_lines: if self.yamlSeperator_pattern.match(line): if self.yaml_start is None: self.yaml_start = line_num elif self.yaml_end is None: self.yaml_end = line_num break else: if self.yaml_start is None: # no yaml section found self.yaml_start = 0 self.yaml_end = 0 break else: yaml_lines.append(line) line_num += 1 # TODO: start != Note and end == None # set fmatter obj -empty dict if yaml not found try: self.fmatter = yaml.load(''.join(yaml_lines), Loader=yaml.FullLoader) or {} except Exception as e: # probably a bad file (i.e. missing ending yaml delimiter raise EditFrontMatter_Exception("yaml.load error -> self.file_path: {file_path}". format(file_path=self.file_path), e) from e
[docs] def writeFile(self, file_path=None, *args, **kwargs) -> bool: """ Write to arg `file_path`, attr :attr:`file_path` Note: If the original file source data was empty after a call to :func:`readFile`, no attempt is maid to write to the file. Args: file_path (str): optional file path Returns: * `True` if file was written * 'False` if file was **not** written """ if not self.has_source_data(): return False if not file_path: file_path = self.file_path try: with open(file_path, "w+") as fo: fo.write(self.dumpFileData()) except IOError as e: raise EditFrontMatter_Exception("write to file -> self.file_path: {file_path}". format(file_path=self.file_path), e) from e return True
[docs] def add_JinjaFilter(self, name, func, *args, **kwargs) -> None: """ Add a `Jinja filter <http://jinja.pocoo.org/docs/2.10/templates/#filters>`_ for setting a jinja2 template variable programmatically through callback. Args: name (str): Jinja template variable name func (object): callback function that will set `name` :Example of implementing a filter for callback: Programatically change the value of `draft` field in the source document `example1.md` using a Jinja2 filter :jinja2 template: .. literalinclude:: ../../../examples/data/template1.j2 :language: jinja :mardown file: .. literalinclude:: ../../../examples/data/example1.md :language: md :Example code: .. testcode:: from editfrontmatter import EditFrontMatter import os # a jinja2 filter callback function def canPublish_func(val): # do some processing.... return True # stringify the template file template_str = ''.join(open(os.environ.get("TEST_DATA_DIR") + "template1.j2", "r").readlines()) # creating object obj = EditFrontMatter(file_path = os.environ.get("TEST_DATA_DIR") + "example1.md", template_str = template_str) # set `canPublish_func` function for our `draft` field callback using # `canPublish` template variable. obj.add_JinjaFilter('canPublish', canPublish_func) # process the front matter from 'example1.md'. `draft` in the front # matter will be set to `true` obj.run() # print the new file contents (uncomment to see dump) # print(obj.dumpFileData()) """ self.jinja2_env.filters[name] = func
[docs] def del_JinjaFilter(self, name, *args, **kwargs) -> bool: """ Delete a Jinja filter. Args: name (str): filter key :returns: * True if filter found and deleted * False if filter not found """ # TODO: check filter exists (assert?) del self.jinja2_env.filters[name] return True
[docs] def dumpFrontMatter(self, *args, **kwargs) -> str: """ Dump `fmatter` as a string Returns: :attr:`fmatter` content as a string """ return yaml.dump(self.fmatter, default_flow_style=False)
[docs] def dumpFileData(self, *args, **kwargs) -> str: """ Concatenate frontmatter with original content and return as a string. Returns: * A string formed by the concatination of :attr:`fmatter` and \ :attr:`file_lines` (if file souce content is **not** empty* **and** yaml was present). * An empty string if file source content is empty *or* original \ yaml was *not* present. Hint: :attr:`fmatter` and :attr:`file_lines` are still available if needed beyond this method. One way of managing this data would be to manually prepend yaml front matter to :attr:`file_lines` and rerun :func:`readFile` before calling this function again. """ # accomidate for empty file or accomidate for empty front matter if self.has_source_data() and self.has_source_yaml(): return self.yaml_delim + "\n" + self.dumpFrontMatter() + \ self.yaml_delim + "\n" + ''.join(self.file_lines[self.yaml_end + 1:]) return ""
[docs] def has_source_yaml(self) -> bool: """ Checks if the yaml is empty after a call to :func:`readFile` Note: This return value does not rely on :attr:`fmatter` since that attribute is subject to change once :func:`readFile` is called. Returns: * `True` if the *original* source file data did have a yaml section * `False` if the *original* source file data did *not* have a yaml section """ if self.yaml_start == 0 and self.yaml_end == 0: return False return True
[docs] def has_source_data(self) -> bool: """ Checks if the source file data is empty Returns: * `True` if the source data content **is** empty * `False` if the source data is **not** empty """ return True if len(self.file_lines) > 0 else False
[docs] def run(self, extraVars_dict={}, *args, **kwargs) -> None: """Process the front matter changes and delete previously specified yaml keys. Variables to change yaml data are passed as a dictionary argument as `extraVars_dict`. Args: extraVars_dict (dict): key,value pairs to be set or added in the :attr:`fmatter` object. Example: Call EditFrontMatter.run() with variables to change in :attr:`fmatter`:: proc = EditFrontMatter() ... proc.run({'hasMath': True, 'addedVariable': ['one', 'two', 'three']}) ... """ # render jinja2 into yaml fmatter_replacement = yaml.load(self.jinja2_env.from_string( self.template_str).render(extraVars_dict), Loader=yaml.FullLoader) # update the original front matter # TODO: exception if fmatter:None self.fmatter.update(fmatter_replacement) # remove unwanted keys (preserved order from oyaml" for key in self.keys_toDelete: if key in self.fmatter: del self.fmatter[key]