mirror of https://github.com/patacrep/patacrep.git
Luthaf
11 years ago
17 changed files with 755 additions and 250 deletions
@ -0,0 +1,226 @@ |
|||
#!/usr/bin/env python |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
"""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 list of instances of the |
|||
Content class. |
|||
|
|||
# Plugin definition |
|||
|
|||
A plugin is a submodule of this module, 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. |
|||
|
|||
# Parsers |
|||
|
|||
A parser is a function which takes as arguments: |
|||
- keyword: the keyword triggering this function; |
|||
- argument: the argument of the keyword (see below); |
|||
- contentlist: the list of content, that is, the part of the list |
|||
following the keyword (see example below); |
|||
- config: the configuration object of the current songbook. Plugins can |
|||
change it. |
|||
|
|||
A parser returns a list of instances of the Content class, defined in |
|||
this module (or of subclasses of this class). |
|||
|
|||
Example: When the following piece of content is met |
|||
|
|||
["sorted(author, @title)", "a_song.sg", "another_song.sg"] |
|||
|
|||
the parser associated to keyword 'sorted' get the arguments: |
|||
- keyword = "sorted" |
|||
- argument = "author, @title" |
|||
- contentlist = ["a_song.sg", "another_song.sg"] |
|||
- config = <the config file of the current songbook>. |
|||
|
|||
# Keyword |
|||
|
|||
A keyword is either an identifier (alphanumeric characters, and underscore), |
|||
or such an identifier, with some text surrounded by parenthesis (like a |
|||
function definition); this text is called the argument to the keyword. |
|||
Examples: |
|||
- sorted |
|||
- sorted(author, @title) |
|||
- cwd(some/path) |
|||
|
|||
If the keyword has an argument, it can be anything, given that it is |
|||
surrounded by parenthesis. It is up to the plugin to parse this argument. For |
|||
intance, keyword "foo()(( bar()" is a perfectly valid keyword, and the parser |
|||
associated to "foo" will get as argument the string ")(( bar(". |
|||
|
|||
# Content class |
|||
|
|||
The content classes are subclasses of class Content defined in this module. |
|||
Content is a perfectly valid class, but instances of it will not generate |
|||
anything in the resulting .tex. |
|||
|
|||
More documentation in the docstring of Content. |
|||
|
|||
""" |
|||
|
|||
import glob |
|||
import importlib |
|||
import jinja2 |
|||
import logging |
|||
import os |
|||
import re |
|||
|
|||
from songbook_core import files |
|||
from songbook_core.errors import SongbookError |
|||
|
|||
LOGGER = logging.getLogger(__name__) |
|||
EOL = '\n' |
|||
|
|||
#pylint: disable=no-self-use |
|||
class Content(object): |
|||
"""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.Content object of the previous item. |
|||
- __context: see Content() 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(SongbookError): |
|||
"""Error in a content plugin.""" |
|||
def __init__(self, keyword, message): |
|||
super(ContentError, self).__init__() |
|||
self.keyword = keyword |
|||
self.message = message |
|||
|
|||
def __str__(self): |
|||
return "Content: {}: {}".format(self.keyword, self.message) |
|||
|
|||
def load_plugins(): |
|||
"""Load all content plugins, and return a dictionary of those plugins. |
|||
|
|||
Return value: a dictionary where: |
|||
- keys are the keywords ; |
|||
- values are functions triggered when this keyword is met. |
|||
""" |
|||
plugins = {} |
|||
for name in glob.glob(os.path.join(os.path.dirname(__file__), "*.py")): |
|||
if name.endswith(".py") and os.path.basename(name) != "__init__.py": |
|||
plugin = importlib.import_module( |
|||
'songbook_core.content.{}'.format( |
|||
os.path.basename(name[:-len('.py')]) |
|||
) |
|||
) |
|||
for (key, value) in plugin.CONTENT_PLUGINS.items(): |
|||
if key in plugins: |
|||
LOGGER.warning( |
|||
"File %s: Keyword '%s' is already used. Ignored.", |
|||
files.relpath(name), |
|||
key, |
|||
) |
|||
continue |
|||
plugins[key] = value |
|||
return plugins |
|||
|
|||
@jinja2.contextfunction |
|||
def render_content(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 Content() instances, as the one that was returned by |
|||
process_content(). |
|||
""" |
|||
rendered = "" |
|||
previous = None |
|||
last = None |
|||
for elem in content: |
|||
if not isinstance(elem, Content): |
|||
LOGGER.error("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 isinstance(last, Content): |
|||
rendered += last.end_block(context) + EOL |
|||
|
|||
return rendered |
|||
|
|||
def process_content(content, config=None): |
|||
"""Process content, and return a list of Content() objects. |
|||
|
|||
Arguments are: |
|||
- content: the content field of the .sb file, which should be a list, and |
|||
describe what is to be included in the songbook; |
|||
- config: the configuration dictionary of the current songbook. |
|||
|
|||
Return: a list of Content objects, corresponding to the content to be |
|||
included in the .tex file. |
|||
""" |
|||
contentlist = [] |
|||
plugins = load_plugins() |
|||
keyword_re = re.compile(r'^ *(?P<keyword>\w*) *(\((?P<argument>.*)\))? *$') |
|||
if not content: |
|||
content = [["song"]] |
|||
for elem in content: |
|||
if isinstance(elem, basestring): |
|||
elem = ["song", elem] |
|||
if len(content) == 0: |
|||
content = ["song"] |
|||
try: |
|||
match = keyword_re.match(elem[0]).groupdict() |
|||
except AttributeError: |
|||
raise ContentError(elem[0], "Cannot parse content type.") |
|||
(keyword, argument) = (match['keyword'], match['argument']) |
|||
if keyword not in plugins: |
|||
raise ContentError(keyword, "Unknown content type.") |
|||
contentlist.extend(plugins[keyword]( |
|||
keyword, |
|||
argument=argument, |
|||
contentlist=elem[1:], |
|||
config=config, |
|||
)) |
|||
return contentlist |
@ -0,0 +1,39 @@ |
|||
#!/usr/bin/env python |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
"""Change base directory before importing songs.""" |
|||
|
|||
import os |
|||
|
|||
from songbook_core.content import process_content |
|||
|
|||
#pylint: disable=unused-argument |
|||
def parse(keyword, config, argument, contentlist): |
|||
"""Return a list songs included in contentlist, whith a different base path. |
|||
|
|||
Arguments: |
|||
- keyword: unused; |
|||
- config: the current songbook configuration dictionary; |
|||
- argument: a directory; |
|||
- contentlist: songbook content, that is parsed by |
|||
songbook_core.content.process_content(). |
|||
|
|||
This function adds 'argument' to the directories where songs are searched |
|||
for, and then processes the content. |
|||
|
|||
The 'argument' is added: |
|||
- first as a relative path to the current directory; |
|||
- then as a relative path to every path already present in |
|||
config['songdir']. |
|||
""" |
|||
old_songdir = config['_songdir'] |
|||
config['_songdir'] = ( |
|||
[argument] + |
|||
[os.path.join(path, argument) for path in config['_songdir']] + |
|||
config['_songdir'] |
|||
) |
|||
processed_content = process_content(contentlist, config) |
|||
config['_songdir'] = old_songdir |
|||
return processed_content |
|||
|
|||
CONTENT_PLUGINS = {'cwd': parse} |
@ -0,0 +1,60 @@ |
|||
#!/usr/bin/env python |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
"""Allow LaTeX sections (starred or not) as content of a songbook.""" |
|||
|
|||
from songbook_core.content import Content, ContentError |
|||
|
|||
KEYWORDS = [ |
|||
"part", |
|||
"chapter", |
|||
"section", |
|||
"subsection", |
|||
"subsubsection", |
|||
"paragraph", |
|||
"subparagraph", |
|||
] |
|||
FULL_KEYWORDS = KEYWORDS + ["{}*".format(word) for word in KEYWORDS] |
|||
|
|||
class Section(Content): |
|||
"""A LaTeX section.""" |
|||
|
|||
def __init__(self, keyword, name, short=None): |
|||
self.keyword = keyword |
|||
self.name = name |
|||
self.short = short |
|||
|
|||
def render(self, __context): |
|||
if self.short is None: |
|||
return r'\{}{{{}}}'.format(self.keyword, self.name) |
|||
else: |
|||
return r'\{}[{}]{{{}}}'.format(self.keyword, self.short, self.name) |
|||
|
|||
#pylint: disable=unused-argument |
|||
def parse(keyword, argument, contentlist, config): |
|||
"""Parse the contentlist. |
|||
|
|||
Arguments: |
|||
- keyword (one of "part", "chapter", "section", ... , "subparagraph", and |
|||
their starred versions "part*", "chapter*", ... , "subparagraph*"): the |
|||
section to use; |
|||
- argument: unused; |
|||
- contentlist: a list of one or two strings, which are the names (short |
|||
and long) of the section; |
|||
- config: configuration dictionary of the current songbook. |
|||
""" |
|||
if (keyword not in KEYWORDS) and (len(contentlist) != 1): |
|||
raise ContentError( |
|||
keyword, |
|||
"Starred section names must have exactly one argument." |
|||
) |
|||
if (len(contentlist) not in [1, 2]): |
|||
raise ContentError(keyword, "Section can have one or two arguments.") |
|||
return [Section(keyword, *contentlist)] #pylint: disable=star-args |
|||
|
|||
|
|||
CONTENT_PLUGINS = dict([ |
|||
(word, parse) |
|||
for word |
|||
in FULL_KEYWORDS |
|||
]) |
@ -0,0 +1,114 @@ |
|||
#!/usr/bin/env python |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
"""Plugin to include songs to the songbook.""" |
|||
|
|||
import glob |
|||
import jinja2 |
|||
import logging |
|||
import os |
|||
|
|||
from songbook_core.content import Content, process_content, ContentError |
|||
from songbook_core import files |
|||
from songbook_core.songs import Song |
|||
|
|||
LOGGER = logging.getLogger(__name__) |
|||
|
|||
class SongRenderer(Content, Song): |
|||
"""Render a song in the .tex file.""" |
|||
|
|||
def begin_new_block(self, previous, __context): |
|||
"""Return a boolean stating if a new block is to be created.""" |
|||
return not isinstance(previous, SongRenderer) |
|||
|
|||
def begin_block(self, context): |
|||
"""Return the string to begin a block.""" |
|||
indexes = context.resolve("indexes") |
|||
if isinstance(indexes, jinja2.runtime.Undefined): |
|||
indexes = "" |
|||
return r'\begin{songs}{%s}' % indexes |
|||
|
|||
def end_block(self, __context): |
|||
"""Return the string to end a block.""" |
|||
return r'\end{songs}' |
|||
|
|||
def render(self, context): |
|||
"""Return the string that will render the song.""" |
|||
return r'\input{{{}}}'.format(files.relpath( |
|||
self.path, |
|||
os.path.dirname(context['filename']) |
|||
)) |
|||
|
|||
#pylint: disable=unused-argument |
|||
def parse(keyword, argument, contentlist, config): |
|||
"""Parse data associated with keyword 'song'. |
|||
|
|||
Arguments: |
|||
- keyword: unused; |
|||
- argument: unused; |
|||
- contentlist: a list of strings, which are interpreted as regular |
|||
expressions (interpreted using the glob module), referring to songs. |
|||
- config: the current songbook configuration dictionary. |
|||
|
|||
Return a list of SongRenderer() instances. |
|||
""" |
|||
if 'languages' not in config: |
|||
config['_languages'] = set() |
|||
songlist = [] |
|||
for songdir in config['_songdir']: |
|||
if contentlist: |
|||
break |
|||
contentlist = [ |
|||
files.relpath(filename, songdir) |
|||
for filename |
|||
in files.recursive_find(songdir, "*.sg") |
|||
] |
|||
for elem in contentlist: |
|||
before = len(songlist) |
|||
for songdir in config['_songdir']: |
|||
for filename in glob.iglob(os.path.join(songdir, elem)): |
|||
LOGGER.debug('Parsing file "{}"…'.format(filename)) |
|||
song = SongRenderer(filename, config) |
|||
songlist.append(song) |
|||
config["_languages"].update(song.languages) |
|||
if len(songlist) > before: |
|||
break |
|||
if len(songlist) == before: |
|||
# No songs were added |
|||
LOGGER.warning( |
|||
"Expression '{}' did not match any file".format(elem) |
|||
) |
|||
return songlist |
|||
|
|||
|
|||
CONTENT_PLUGINS = {'song': parse} |
|||
|
|||
|
|||
class OnlySongsError(ContentError): |
|||
"A list that should contain only songs also contain other type of content." |
|||
def __init__(self, not_songs): |
|||
super(OnlySongsError, self).__init__() |
|||
self.not_songs = not_songs |
|||
|
|||
def __str__(self): |
|||
return ( |
|||
"Only songs are allowed, and the following items are not:" + |
|||
str(self.not_songs) |
|||
) |
|||
|
|||
def process_songs(content, config=None): |
|||
"""Process content that containt only songs. |
|||
|
|||
Call songbook_core.content.process_content(), checks if the returned list |
|||
contains only songs, and raise an exception if not. |
|||
""" |
|||
contentlist = process_content(content, config) |
|||
not_songs = [ |
|||
item |
|||
for item |
|||
in contentlist |
|||
if not isinstance(item, SongRenderer) |
|||
] |
|||
if not_songs: |
|||
raise OnlySongsError(not_songs) |
|||
return contentlist |
@ -0,0 +1,46 @@ |
|||
#!/usr/bin/env python |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
"""Allow 'songchapter' and 'songsection' as content of a songbook.""" |
|||
|
|||
from songbook_core.content import Content, ContentError |
|||
|
|||
KEYWORDS = [ |
|||
"songchapter", |
|||
"songsection", |
|||
] |
|||
|
|||
class SongSection(Content): |
|||
"""A songsection or songchapter.""" |
|||
|
|||
def __init__(self, keyword, name): |
|||
self.keyword = keyword |
|||
self.name = name |
|||
|
|||
def render(self, __context): |
|||
"""Render this section or chapter.""" |
|||
return r'\{}{{{}}}'.format(self.keyword, self.name) |
|||
|
|||
#pylint: disable=unused-argument |
|||
def parse(keyword, argument, contentlist, config): |
|||
"""Parse the contentlist. |
|||
|
|||
Arguments: |
|||
- keyword ("songsection" or "songchapter"): the section to use; |
|||
- argument: unused; |
|||
- contentlist: a list of one string, which is the name of the section; |
|||
- config: configuration dictionary of the current songbook. |
|||
""" |
|||
if (keyword not in KEYWORDS) and (len(contentlist) != 1): |
|||
raise ContentError( |
|||
keyword, |
|||
"Starred section names must have exactly one argument.", |
|||
) |
|||
return [SongSection(keyword, contentlist[0])] |
|||
|
|||
|
|||
CONTENT_PLUGINS = dict([ |
|||
(word, parse) |
|||
for word |
|||
in KEYWORDS |
|||
]) |
@ -0,0 +1,96 @@ |
|||
#!/usr/bin/env python |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
"""Sorted list of songs. |
|||
|
|||
This plugin provides keyword 'sorted', used to include a sorted list of songs |
|||
to a songbook. |
|||
""" |
|||
|
|||
import locale |
|||
import logging |
|||
|
|||
from songbook_core import files |
|||
from songbook_core.content import ContentError |
|||
from songbook_core.content.song import OnlySongsError, process_songs |
|||
|
|||
LOGGER = logging.getLogger(__name__) |
|||
|
|||
DEFAULT_SORT = ['by', 'album', '@title'] |
|||
|
|||
def normalize_string(string): |
|||
"""Return a normalized string. |
|||
|
|||
Normalized means: |
|||
- no surrounding spaces; |
|||
- lower case; |
|||
- passed through locale.strxfrm(). |
|||
""" |
|||
return locale.strxfrm(string.lower().strip()) |
|||
|
|||
def normalize_field(field): |
|||
"""Return a normalized field, it being a string or a list of strings.""" |
|||
if isinstance(field, basestring): |
|||
return normalize_string(field) |
|||
elif isinstance(field, list): |
|||
return [normalize_string(string) for string in field] |
|||
|
|||
def key_generator(sort): |
|||
"""Return a function that returns the list of values used to sort the song. |
|||
|
|||
Arguments: |
|||
- sort: the list of keys used to sort. |
|||
""" |
|||
|
|||
def ordered_song_keys(song): |
|||
"""Return the list of values used to sort the song.""" |
|||
songkey = [] |
|||
for key in sort: |
|||
if key == "@title": |
|||
field = song.unprefixed_titles |
|||
elif key == "@path": |
|||
field = song.path |
|||
elif key == "by": |
|||
field = song.authors |
|||
else: |
|||
try: |
|||
field = song.args[key] |
|||
except KeyError: |
|||
LOGGER.debug( |
|||
"Ignoring unknown key '{}' for song {}.".format( |
|||
key, |
|||
files.relpath(song.path), |
|||
) |
|||
) |
|||
field = "" |
|||
songkey.append(normalize_field(field)) |
|||
return songkey |
|||
return ordered_song_keys |
|||
|
|||
#pylint: disable=unused-argument |
|||
def parse(keyword, config, argument, contentlist): |
|||
"""Return a sorted list of songs contained in 'contentlist'. |
|||
|
|||
Arguments: |
|||
- keyword: the string 'sorted'; |
|||
- config: the current songbook configuration dictionary; |
|||
- argument: the list of the fields used to sort songs, as strings |
|||
separated by commas (e.g. "by, album, @title"); |
|||
- contentlist: the list of content to be sorted. If this content |
|||
contain something else than a song, an exception is raised. |
|||
""" |
|||
if argument: |
|||
sort = [key.strip() for key in argument.split(",")] |
|||
else: |
|||
sort = DEFAULT_SORT |
|||
try: |
|||
songlist = process_songs(contentlist, config) |
|||
except OnlySongsError as error: |
|||
raise ContentError(keyword, ( |
|||
"Content list of this keyword can be only songs (or content " |
|||
"that result into songs), and the following are not:" + |
|||
str(error.not_songs) |
|||
)) |
|||
return sorted(songlist, key=key_generator(sort)) |
|||
|
|||
CONTENT_PLUGINS = {'sorted': parse} |
@ -0,0 +1,58 @@ |
|||
#!/usr/bin/env python |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
"""Include LaTeX raw code in the songbook.""" |
|||
|
|||
import logging |
|||
import os |
|||
|
|||
from songbook_core import files |
|||
from songbook_core.content import Content |
|||
|
|||
LOGGER = logging.getLogger(__name__) |
|||
|
|||
class LaTeX(Content): |
|||
"""Inclusion of LaTeX code""" |
|||
|
|||
def __init__(self, filename): |
|||
self.filename = filename |
|||
|
|||
def render(self, context): |
|||
return r'\input{{{}}}'.format(files.relpath( |
|||
self.filename, |
|||
os.path.dirname(context['filename']), |
|||
)) |
|||
|
|||
#pylint: disable=unused-argument |
|||
def parse(keyword, argument, contentlist, config): |
|||
"""Parse the contentlist. |
|||
|
|||
Arguments: |
|||
- keyword: unused; |
|||
- argument: unused; |
|||
- contentlist: a list of name of tex files; |
|||
- config: configuration dictionary of the current songbook. |
|||
""" |
|||
if not contentlist: |
|||
LOGGER.warning( |
|||
"Useless 'tex' content: list of files to include is empty." |
|||
) |
|||
filelist = [] |
|||
for filename in contentlist: |
|||
checked_file = None |
|||
for path in config['_songdir']: |
|||
if os.path.exists(os.path.join(path, filename)): |
|||
checked_file = os.path.relpath(os.path.join(path, filename)) |
|||
break |
|||
if not checked_file: |
|||
LOGGER.warning( |
|||
("Cannot find file '{}' in '{}'. Compilation may fail " |
|||
"later.").format(filename, str(config['_songdir'])) |
|||
) |
|||
continue |
|||
filelist.append(LaTeX(checked_file)) |
|||
|
|||
return filelist |
|||
|
|||
|
|||
CONTENT_PLUGINS = {'tex': parse} |
Loading…
Reference in new issue