Engine for LaTeX songbooks http://www.patacrep.com
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

285 lines
8.5 KiB

"""Content plugin management.
Content that can be included in a songbook is controlled by plugins. From the
user (or .sb file) point of view, each piece of content is introduced by a
keyword. This keywold is associated with a plugin (a submodule of this very
module), which parses the content, and return a ContentList object, which is
little more than a list of instances of the ContentItem class.
# Plugin definition
A plugin is a submodule of this module, (or a python file in directory
<datadir>/python/content), which have a variable CONTENT_PLUGINS, which is a
dictionary where:
- keys are keywords,
- values are parsers (see below).
When analysing the content field of the .sb file, when those keywords are
met, the corresponding parser is called.
# Keyword examples
- sort
- section*
- cwd
# Parsers
A parser is a function which takes as arguments:
- keyword: the keyword triggering this function;
- argument: the argument of the keyword (see below);
- config: the configuration object of the current songbook. Plugins can
change it.
A parser returns a ContentList object (a list of instances of the ContentItem
class), defined in this module (or of subclasses of this class).
Example: When the following piece of content is met
sort:
key: ["author", "title"]
content:
- "a_song.sg"
- "another_song.sg"
the parser associated to keyword 'sort' get the arguments:
- keyword = "sort"
- argument = {
'key': ["author", "title"],
'content': ["a_song.sg", "another_song.sg"],
}
- config = <the config file of the current songbook>.
# ContentItem class
The content classes are subclasses of class ContentItem defined in this module.
ContentItem is a perfectly valid class, but instances of it will not generate
anything in the resulting .tex.
More documentation in the docstring of ContentItem.
"""
import glob
import logging
import os
import re
import sys
import jinja2
import yaml
from patacrep import files, Rx
from patacrep.errors import SharedError
LOGGER = logging.getLogger(__name__)
EOL = '\n'
#pylint: disable=no-self-use
class ContentItem:
"""Content item. Will render to something in the .tex file.
The current jinja2.runtime.Context is passed to all function defined
here.
"""
def render(self, __context):
"""Render this content item.
Returns a string, to be placed verbatim in the generated .tex file.
"""
return ""
# Block management
def begin_new_block(self, __previous, __context):
"""Return a boolean stating if a new block is to be created.
# Arguments
- __previous: the songbook.content.ContentItem object of the previous item.
- __context: see ContentItem() documentation.
# Return
- True if the renderer has to close previous block, and begin a new
one,
- False otherwise (the generated code for this item is part of the
current block).
"""
return True
def begin_block(self, __context):
"""Return the string to begin a block."""
return ""
def end_block(self, __context):
"""Return the string to end a block."""
return ""
class ContentError(SharedError):
"""Error in a content plugin."""
def __init__(self, keyword=None, message=None):
super().__init__()
self.keyword = keyword
self.message = message
def __str__(self):
text = "Content"
if self.keyword is not None:
text += ": " + self.keyword
if self.message is not None:
text += ": " + self.message
return text
@property
def __dict__(self):
parent = vars(super())
parent.update({
'keyword': self.keyword,
'message': self.message,
})
return parent
class ContentList:
"""List of content items"""
def __init__(self, *args, **kwargs):
self._content = list(*args, **kwargs)
self._errors = []
def __iter__(self):
yield from self._content
def extend(self, iterator):
"""Extend content list with an iterator.
If the argument is of the same type, the list of errors is
also extended.
"""
self._content.extend(iterator)
if isinstance(iterator, ContentList):
self._errors.extend(iterator.iter_errors())
def append(self, item):
"""Append an item to the content list."""
return self._content.append(item)
def __len__(self):
return len(self._content)
def append_error(self, error):
"""Log and append an error to the error list."""
LOGGER.warning(error)
self._errors.append(error)
def extend_error(self, errors):
"""Extend the error list with the argument, which is logged."""
for error in errors:
self.append_error(error)
def iter_errors(self):
"""Iterate over errors."""
yield from self._errors
for item in self:
if not hasattr(item, "iter_errors"):
continue
yield from item.iter_errors()
class EmptyContentList(ContentList):
"""Empty content list: contain only errors."""
def __init__(self, *, errors):
super().__init__()
for error in errors:
self.append_error(error)
@jinja2.contextfunction
def render(context, content):
"""Render the content of the songbook as a LaTeX code.
Arguments:
- context: the jinja2.runtime.context of the current template
compilation.
- content: a list of ContentItem() instances, as the one that was returned by
process_content().
"""
rendered = ""
previous = None
last = None
for elem in content:
if not isinstance(elem, ContentItem):
LOGGER.warning("Ignoring bad content item '{}'.".format(elem))
continue
last = elem
if elem.begin_new_block(previous, context):
if previous:
rendered += previous.end_block(context) + EOL
rendered += elem.begin_block(context) + EOL
rendered += elem.render(context) + EOL
previous = elem
if last is not None:
rendered += last.end_block(context) + EOL
return rendered
def validate_parser_argument(raw_schema):
"""Check that the parser argument respects the schema
Will raise `ContentError` if the schema is not respected.
"""
rx_checker = Rx.Factory({"register_core_types": True})
schema = rx_checker.make_schema(yaml.load(raw_schema))
def wrap(parse):
"""Wrap the parse function"""
def wrapped(keyword, argument, config):
"""Check the argument schema before calling the plugin parser"""
try:
schema.validate(argument)
except Rx.SchemaMismatch as exception:
msg = 'Invalid syntax:\n---\n{}---\n{}'.format(
yaml.dump({keyword: argument}, default_flow_style=False),
str(exception)
)
raise ContentError(keyword, msg)
return parse(keyword, argument=argument, config=config)
return wrapped
return wrap
def process_content(content, config=None):
"""Process content, and return a list of ContentItem() objects.
Arguments are:
- content: the content field of the .sb file, which should be an imbricated list
and describe what is to be included in the songbook;
- config: the configuration dictionary of the current songbook.
Return: a list of ContentItem objects, corresponding to the content to be
included in the .tex file.
"""
contentlist = ContentList()
plugins = config.get('_content_plugins', {})
if not content:
content = [{'song': None}]
elif isinstance(content, dict):
content = [content]
for elem in content:
if isinstance(elem, str):
elem = {'song': elem}
if isinstance(elem, dict):
for keyword, argument in elem.items():
try:
if keyword not in plugins:
raise ContentError(keyword, "Unknown content keyword.")
contentlist.extend(plugins[keyword](
keyword,
argument=argument,
config=config,
))
except ContentError as error:
contentlist.append_error(error)
else:
contentlist.append_error(ContentError(str(elem), "Unknown content type."))
return contentlist