Browse Source

Merge branch 'master' into yaml_options

pull/184/head
Oliverpool 9 years ago
parent
commit
bf10c6bd46
  1. 10
      .appveyor.yml
  2. 3
      examples/example-test.sb
  3. 5
      examples/songs/tests/errors.csg
  4. 94
      patacrep/build.py
  5. 116
      patacrep/content/__init__.py
  6. 2
      patacrep/content/cwd.py
  7. 27
      patacrep/content/include.py
  8. 23
      patacrep/content/section.py
  9. 39
      patacrep/content/song.py
  10. 23
      patacrep/content/songsection.py
  11. 7
      patacrep/content/sorted.py
  12. 15
      patacrep/content/tex.py
  13. 14
      patacrep/errors.py
  14. 2
      patacrep/index.py
  15. 73
      patacrep/latex/__init__.py
  16. 2
      patacrep/latex/lexer.py
  17. 17
      patacrep/latex/syntax.py
  18. 37
      patacrep/songs/__init__.py
  19. 68
      patacrep/songs/chordpro/__init__.py
  20. 17
      patacrep/songs/chordpro/ast.py
  21. 19
      patacrep/songs/chordpro/lexer.py
  22. 34
      patacrep/songs/chordpro/syntax.py
  23. 88
      patacrep/songs/errors.py
  24. 21
      patacrep/songs/syntax.py
  25. 55
      patacrep/templates.py
  26. 0
      test/test_content/datadir/songs/custom_list.json
  27. 3
      test/test_song/errors/invalid_content.csg.source
  28. 20
      test/test_song/test_parser.py
  29. 18
      test/test_songbook/content.sb
  30. 159
      test/test_songbook/content.tex.control
  31. 1
      test/test_songbook/content_datadir/content/foo.tex
  32. 5
      test/test_songbook/content_datadir/content/inter.isg
  33. 2
      test/test_songbook/content_datadir/content/song.csg
  34. 3
      test/test_songbook/content_datadir/content/song.tsg
  35. 1
      test/test_songbook/content_datadir/songs/include.sbc
  36. 5
      test/test_songbook/content_datadir/songs/inter.isg
  37. 3
      test/test_songbook/content_datadir/songs/song.csg
  38. 3
      test/test_songbook/content_datadir/songs/song.tsg
  39. 2
      texlive_packages.txt

10
.appveyor.yml

@ -31,12 +31,16 @@ install:
# Let the binaries be directly callable # Let the binaries be directly callable
- cmd: set PATH=%PATH%;C:\projects\patacrep\miktex\miktex\bin - cmd: set PATH=%PATH%;C:\projects\patacrep\miktex\miktex\bin
# Update fonts # Update some packages to prevent ltluatex bug
- cmd: luaotfload-tool.exe --update - cmd: mpm.exe --update=miktex-bin-2.9
- cmd: mpm.exe --update=ltxbase --update=luatexbase --update=luaotfload --update=miktex-luatex-base --update=fontspec
# Manually install required texlive packages # Manually install required texlive packages
- cmd: mpm.exe --install-some texlive_packages.txt - cmd: mpm.exe --install-some texlive_packages.txt
# Update fonts
- cmd: luaotfload-tool.exe --update
build: false # Not a C# project, build stuff at the test step instead. build: false # Not a C# project, build stuff at the test step instead.
before_test: before_test:
@ -47,4 +51,4 @@ test_script:
# Cache Miktex Portable file # Cache Miktex Portable file
cache: cache:
- C:\projects\patacrep\miktex-portable.exe - C:\projects\patacrep\miktex-portable.exe

3
examples/example-test.sb

@ -5,9 +5,10 @@
"lilypond", "lilypond",
"pictures" "pictures"
], ],
"lang": "ERROR",
"booktype" : "chorded", "booktype" : "chorded",
"template" : "patacrep.tex", "template" : "patacrep.tex",
"encoding": "utf8", "encoding": "utf8",
"content": ["tests/*.sg", "tests/*.sgc"] "content": ["tests/*.csg", "tests/*.tsg"]
} }

5
examples/songs/tests/errors.csg

@ -1,4 +1,4 @@
{lang : en} {lang : xx_XX}
{columns : 2} {columns : 2}
{ title : Error} { title : Error}
{subtitle: A chordpro file with many errors} {subtitle: A chordpro file with many errors}
@ -12,4 +12,5 @@ Bla [H]bla
{fo#: bar} {fo#: bar}
{image: foo}
{partition: bar}

94
patacrep/build.py

@ -1,6 +1,7 @@
"""Build a songbook, according to parameters found in a .sb file.""" """Build a songbook, according to parameters found in a .sb file."""
import codecs import codecs
import copy
import glob import glob
import logging import logging
import threading import threading
@ -32,7 +33,7 @@ GENERATED_EXTENSIONS = [
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
class Songbook(object): class Songbook:
"""Represent a songbook (.sb) file. """Represent a songbook (.sb) file.
- Low level: provide a Python representation of the values stored in the - Low level: provide a Python representation of the values stored in the
@ -41,20 +42,21 @@ class Songbook(object):
""" """
def __init__(self, raw_songbook, basename): def __init__(self, raw_songbook, basename):
super().__init__()
# Validate config # Validate config
schema = config_model('schema') schema = config_model('schema')
utils.validate_yaml_schema(raw_songbook, schema) utils.validate_yaml_schema(raw_songbook, schema)
self.config = raw_songbook self._raw_config = raw_songbook
self.basename = basename self.basename = basename
self._errors = list()
self._config = dict()
# Some special keys have their value processed. # Some special keys have their value processed.
self._set_datadir() self._set_datadir()
def _set_datadir(self): def _set_datadir(self):
"""Set the default values for datadir""" """Set the default values for datadir"""
abs_datadir = [] abs_datadir = []
for path in self.config['_datadir']: for path in self._raw_config['_datadir']:
if os.path.exists(path) and os.path.isdir(path): if os.path.exists(path) and os.path.isdir(path):
abs_datadir.append(os.path.abspath(path)) abs_datadir.append(os.path.abspath(path))
else: else:
@ -62,10 +64,10 @@ class Songbook(object):
"Ignoring non-existent datadir '{}'.".format(path) "Ignoring non-existent datadir '{}'.".format(path)
) )
self.config['_datadir'] = abs_datadir self._raw_config['_datadir'] = abs_datadir
self.config['_songdir'] = [ self._raw_config['_songdir'] = [
DataSubpath(path, 'songs') DataSubpath(path, 'songs')
for path in self.config['_datadir'] for path in self._raw_config['_datadir']
] ]
def write_tex(self, output): def write_tex(self, output):
@ -74,45 +76,81 @@ class Songbook(object):
Arguments: Arguments:
- output: a file object, in which the file will be written. - output: a file object, in which the file will be written.
""" """
config = self.config.copy() # Updating configuration
self._config = self._raw_config.copy()
renderer = TexBookRenderer( renderer = TexBookRenderer(
config['book']['template'], self._config['book']['template'],
config['_datadir'], self._config['_datadir'],
config['book']['lang'], self._config['book']['lang'],
config['book']['encoding'], self._config['book']['encoding'],
) )
self._config['_template'] = renderer.get_all_variables(self._config.get('template', {}))
config['_template'] = renderer.get_all_variables(self.config.get('template', {})) self._config['_compiled_authwords'] = authors.compile_authwords(
copy.deepcopy(self._config['authors'])
config['_compiled_authwords'] = authors.compile_authwords(config['authors']) )
# Loading custom plugins # Loading custom plugins
config['_content_plugins'] = files.load_plugins( self._config['_content_plugins'] = files.load_plugins(
datadirs=config['_datadir'], datadirs=self._config['_datadir'],
root_modules=['content'], root_modules=['content'],
keyword='CONTENT_PLUGINS', keyword='CONTENT_PLUGINS',
) )
config['_song_plugins'] = files.load_plugins( self._config['_song_plugins'] = files.load_plugins(
datadirs=config['_datadir'], datadirs=self._config['_datadir'],
root_modules=['songs'], root_modules=['songs'],
keyword='SONG_RENDERERS', keyword='SONG_RENDERERS',
)['tsg'] )['tsg']
# Configuration set # Configuration set
config['render'] = content.render self._config['render'] = content.render
config['content'] = content.process_content( self._config['content'] = content.process_content(
config.get('content', []), self._config.get('content', []),
config, self._config,
) )
config['filename'] = output.name[:-4] self._config['filename'] = output.name[:-4]
self._config['bookoptions'] = iter_bookoptions(self._config)
config['bookoptions'] = iter_bookoptions(config) renderer.render_tex(output, self._config)
self._errors.extend(renderer.errors)
renderer.render_tex(output, config) def has_errors(self):
"""Return `True` iff errors have been encountered in the book.
Note that `foo.has_errors() == False` does not means that the book has
not any errors: it does only mean that no error has been found yet.
"""
for _ in self.iter_errors():
return True
return False
def iter_errors(self):
"""Iterate over errors of book and book content."""
yield from self._errors
contentlist = self._config.get('content', content.ContentList())
yield from contentlist.iter_errors()
def iter_flat_errors(self):
"""Iterate over errors, in an exportable format.
This function does the same job as :func:`iter_errors`, exepted that
the errors are represented as dictionaries of standard python types.
Each error (dictionary) contains the following keys:
- `type`: the error type (as the class name of the error);
- `message`: Error message, that does not include the error location (datadir, song, etc.);
- `full_message`: Error message, containing the full error location;
- depending on the error type, more keys may be present in the error.
"""
for error in self.iter_errors():
yield vars(error)
def requires_lilypond(self): def requires_lilypond(self):
"""Tell if lilypond is part of the bookoptions""" """Tell if lilypond is part of the bookoptions"""
return 'lilypond' in self.config.get('bookoptions', []) return ('chords' in self._config
and 'lilypond' in self._config['chords']
and self._config['chords']['lilypond'])
def _log_pipe(pipe): def _log_pipe(pipe):
"""Log content from `pipe`.""" """Log content from `pipe`."""
@ -122,7 +160,7 @@ def _log_pipe(pipe):
break break
LOGGER.debug(line.strip()) LOGGER.debug(line.strip())
class SongbookBuilder(object): class SongbookBuilder:
"""Provide methods to compile a songbook.""" """Provide methods to compile a songbook."""
# if False, do not expect anything from stdin. # if False, do not expect anything from stdin.

116
patacrep/content/__init__.py

@ -3,8 +3,8 @@
Content that can be included in a songbook is controlled by plugins. From the 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 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 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 module), which parses the content, and return a ContentList object, which is
Content class. little more than a list of instances of the ContentItem class.
# Plugin definition # Plugin definition
@ -27,8 +27,8 @@ A parser is a function which takes as arguments:
- config: the configuration object of the current songbook. Plugins can - config: the configuration object of the current songbook. Plugins can
change it. change it.
A parser returns a list of instances of the Content class, defined in A parser returns a ContentList object (a list of instances of the ContentItem
this module (or of subclasses of this class). class), defined in this module (or of subclasses of this class).
Example: When the following piece of content is met Example: When the following piece of content is met
@ -55,13 +55,13 @@ 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 intance, keyword "foo()(( bar()" is a perfectly valid keyword, and the parser
associated to "foo" will get as argument the string ")(( bar(". associated to "foo" will get as argument the string ")(( bar(".
# Content class # ContentItem class
The content classes are subclasses of class Content defined in this module. The content classes are subclasses of class ContentItem defined in this module.
Content is a perfectly valid class, but instances of it will not generate ContentItem is a perfectly valid class, but instances of it will not generate
anything in the resulting .tex. anything in the resulting .tex.
More documentation in the docstring of Content. More documentation in the docstring of ContentItem.
""" """
@ -74,13 +74,13 @@ import sys
import jinja2 import jinja2
from patacrep import files from patacrep import files
from patacrep.errors import SongbookError from patacrep.errors import SharedError
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
EOL = '\n' EOL = '\n'
#pylint: disable=no-self-use #pylint: disable=no-self-use
class Content(object): class ContentItem:
"""Content item. Will render to something in the .tex file. """Content item. Will render to something in the .tex file.
The current jinja2.runtime.Context is passed to all function defined The current jinja2.runtime.Context is passed to all function defined
@ -101,8 +101,8 @@ class Content(object):
# Arguments # Arguments
- __previous: the songbook.content.Content object of the previous item. - __previous: the songbook.content.ContentItem object of the previous item.
- __context: see Content() documentation. - __context: see ContentItem() documentation.
# Return # Return
@ -121,15 +121,81 @@ class Content(object):
"""Return the string to end a block.""" """Return the string to end a block."""
return "" return ""
class ContentError(SongbookError): class ContentError(SharedError):
"""Error in a content plugin.""" """Error in a content plugin."""
def __init__(self, keyword, message): def __init__(self, keyword=None, message=None):
super().__init__() super().__init__()
self.keyword = keyword self.keyword = keyword
self.message = message self.message = message
def __str__(self): def __str__(self):
return "Content: {}: {}".format(self.keyword, self.message) 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 @jinja2.contextfunction
def render(context, content): def render(context, content):
@ -138,15 +204,15 @@ def render(context, content):
Arguments: Arguments:
- context: the jinja2.runtime.context of the current template - context: the jinja2.runtime.context of the current template
compilation. compilation.
- content: a list of Content() instances, as the one that was returned by - content: a list of ContentItem() instances, as the one that was returned by
process_content(). process_content().
""" """
rendered = "" rendered = ""
previous = None previous = None
last = None last = None
for elem in content: for elem in content:
if not isinstance(elem, Content): if not isinstance(elem, ContentItem):
LOGGER.error("Ignoring bad content item '{}'.".format(elem)) LOGGER.warning("Ignoring bad content item '{}'.".format(elem))
continue continue
last = elem last = elem
@ -157,23 +223,23 @@ def render(context, content):
rendered += elem.render(context) + EOL rendered += elem.render(context) + EOL
previous = elem previous = elem
if isinstance(last, Content): if last is not None:
rendered += last.end_block(context) + EOL rendered += last.end_block(context) + EOL
return rendered return rendered
def process_content(content, config=None): def process_content(content, config=None):
"""Process content, and return a list of Content() objects. """Process content, and return a list of ContentItem() objects.
Arguments are: Arguments are:
- content: the content field of the .sb file, which should be a list, and - content: the content field of the .sb file, which should be a list, and
describe what is to be included in the songbook; describe what is to be included in the songbook;
- config: the configuration dictionary of the current songbook. - config: the configuration dictionary of the current songbook.
Return: a list of Content objects, corresponding to the content to be Return: a list of ContentItem objects, corresponding to the content to be
included in the .tex file. included in the .tex file.
""" """
contentlist = [] contentlist = ContentList()
plugins = config.get('_content_plugins', {}) plugins = config.get('_content_plugins', {})
keyword_re = re.compile(r'^ *(?P<keyword>[\w\*]*) *(\((?P<argument>.*)\))? *$') keyword_re = re.compile(r'^ *(?P<keyword>[\w\*]*) *(\((?P<argument>.*)\))? *$')
if not content: if not content:
@ -184,10 +250,12 @@ def process_content(content, config=None):
try: try:
match = keyword_re.match(elem[0]).groupdict() match = keyword_re.match(elem[0]).groupdict()
except AttributeError: except AttributeError:
raise ContentError(elem[0], "Cannot parse content type.") contentlist.append_error(ContentError(elem[0], "Cannot parse content type."))
continue
(keyword, argument) = (match['keyword'], match['argument']) (keyword, argument) = (match['keyword'], match['argument'])
if keyword not in plugins: if keyword not in plugins:
raise ContentError(keyword, "Unknown content type.") contentlist.append_error(ContentError(keyword, "Unknown content type."))
continue
contentlist.extend(plugins[keyword]( contentlist.extend(plugins[keyword](
keyword, keyword,
argument=argument, argument=argument,

2
patacrep/content/cwd.py

@ -24,7 +24,7 @@ def parse(keyword, config, argument, contentlist):
""" """
old_songdir = config['_songdir'] old_songdir = config['_songdir']
config['_songdir'] = ( config['_songdir'] = (
[DataSubpath("", argument)] + [DataSubpath(".", argument)] +
[path.clone().join(argument) for path in config['_songdir']] [path.clone().join(argument) for path in config['_songdir']]
) )
processed_content = process_content(contentlist, config) processed_content = process_content(contentlist, config)

27
patacrep/content/include.py

@ -6,10 +6,9 @@ songs in JSON format.
import json import json
import os import os
import sys
import logging import logging
from patacrep.content import process_content, ContentError from patacrep.content import process_content, ContentError, ContentList
from patacrep import encoding, errors, files from patacrep import encoding, errors, files
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -19,7 +18,7 @@ def load_from_datadirs(path, datadirs):
Raise an exception if it was found if none of the datadirs of 'config'. Raise an exception if it was found if none of the datadirs of 'config'.
""" """
for filepath in files.iter_datadirs(datadirs, path): for filepath in files.iter_datadirs(datadirs, "songs", path):
if os.path.exists(filepath): if os.path.exists(filepath):
return filepath return filepath
# File not found # File not found
@ -38,10 +37,14 @@ def parse(keyword, config, argument, contentlist):
- argument: None; - argument: None;
- contentlist: a list of file paths to be included. - contentlist: a list of file paths to be included.
""" """
new_contentlist = [] new_contentlist = ContentList()
for path in contentlist: for path in contentlist:
filepath = load_from_datadirs(path, config['_datadir']) try:
filepath = load_from_datadirs(path, config['_datadir'])
except ContentError as error:
new_contentlist.append_error(error)
continue
content_file = None content_file = None
try: try:
with encoding.open_read( with encoding.open_read(
@ -50,13 +53,15 @@ def parse(keyword, config, argument, contentlist):
) as content_file: ) as content_file:
new_content = json.load(content_file) new_content = json.load(content_file)
except Exception as error: # pylint: disable=broad-except except Exception as error: # pylint: disable=broad-except
LOGGER.error(error) new_contentlist.append_error(ContentError(
LOGGER.error("Error while loading file '{}'.".format(filepath)) keyword="include",
sys.exit(1) message="Error while loading file '{}': {}".format(filepath, error),
))
continue
config["_datadir"].append(os.path.abspath(os.path.dirname(filepath))) config['_datadir'].append(os.path.abspath(os.path.dirname(filepath)))
new_contentlist += process_content(new_content, config) new_contentlist.extend(process_content(new_content, config))
config["_datadir"].pop() config['_datadir'].pop()
return new_contentlist return new_contentlist

23
patacrep/content/section.py

@ -1,6 +1,6 @@
"""Allow LaTeX sections (starred or not) as content of a songbook.""" """Allow LaTeX sections (starred or not) as content of a songbook."""
from patacrep.content import Content, ContentError from patacrep.content import ContentItem, ContentError, ContentList, EmptyContentList
KEYWORDS = [ KEYWORDS = [
"part", "part",
@ -13,7 +13,7 @@ KEYWORDS = [
] ]
FULL_KEYWORDS = KEYWORDS + ["{}*".format(word) for word in KEYWORDS] FULL_KEYWORDS = KEYWORDS + ["{}*".format(word) for word in KEYWORDS]
class Section(Content): class Section(ContentItem):
"""A LaTeX section.""" """A LaTeX section."""
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
@ -41,14 +41,17 @@ def parse(keyword, argument, contentlist, config):
and short) of the section; and short) of the section;
- config: configuration dictionary of the current songbook. - config: configuration dictionary of the current songbook.
""" """
if (keyword not in KEYWORDS) and (len(contentlist) != 1): try:
raise ContentError( if (keyword not in KEYWORDS) and (len(contentlist) != 1):
keyword, raise ContentError(
"Starred section names must have exactly one argument." 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.") if (len(contentlist) not in [1, 2]):
return [Section(keyword, *contentlist)] raise ContentError(keyword, "Section can have one or two arguments.")
return ContentList([Section(keyword, *contentlist)])
except ContentError as error:
return EmptyContentList(errors=[error])
CONTENT_PLUGINS = dict([ CONTENT_PLUGINS = dict([

39
patacrep/content/song.py

@ -7,18 +7,23 @@ import textwrap
import jinja2 import jinja2
from patacrep.content import process_content, ContentError, Content from patacrep.content import process_content
from patacrep.content import ContentError, ContentItem, ContentList
from patacrep import files, errors from patacrep import files, errors
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
class SongRenderer(Content): class SongRenderer(ContentItem):
"""Render a song in as a tex code.""" """Render a song in as a tex code."""
def __init__(self, song): def __init__(self, song):
super().__init__() super().__init__()
self.song = song self.song = song
def iter_errors(self):
"""Iterate over song errors."""
yield from self.song.errors
def begin_new_block(self, previous, __context): def begin_new_block(self, previous, __context):
"""Return a boolean stating if a new block is to be created.""" """Return a boolean stating if a new block is to be created."""
return not isinstance(previous, SongRenderer) return not isinstance(previous, SongRenderer)
@ -68,7 +73,7 @@ def parse(keyword, argument, contentlist, config):
plugins = config['_song_plugins'] plugins = config['_song_plugins']
if '_langs' not in config: if '_langs' not in config:
config['_langs'] = set() config['_langs'] = set()
songlist = [] songlist = ContentList()
for songdir in config['_songdir']: for songdir in config['_songdir']:
if contentlist: if contentlist:
break break
@ -85,20 +90,24 @@ def parse(keyword, argument, contentlist, config):
LOGGER.debug('Parsing file "{}"'.format(filename)) LOGGER.debug('Parsing file "{}"'.format(filename))
extension = filename.split(".")[-1] extension = filename.split(".")[-1]
if extension not in plugins: if extension not in plugins:
LOGGER.warning( LOGGER.info(
( (
'I do not know how to parse "{}": name does ' 'Cannot parse "%s": name does not end with one '
'not end with one of {}. Ignored.' 'of %s. Ignored.'
).format( ),
os.path.join(songdir.datadir, filename), os.path.join(songdir.datadir, filename),
", ".join(["'.{}'".format(key) for key in plugins.keys()]), ", ".join(["'.{}'".format(key) for key in plugins.keys()])
)) )
continue
try:
renderer = SongRenderer(plugins[extension](
filename,
config,
datadir=songdir.datadir,
))
except ContentError as error:
songlist.append_error(error)
continue continue
renderer = SongRenderer(plugins[extension](
filename,
config,
datadir=songdir.datadir,
))
songlist.append(renderer) songlist.append(renderer)
config["_langs"].add(renderer.song.lang) config["_langs"].add(renderer.song.lang)
if len(songlist) > before: if len(songlist) > before:

23
patacrep/content/songsection.py

@ -1,13 +1,13 @@
"""Allow 'songchapter' and 'songsection' as content of a songbook.""" """Allow 'songchapter' and 'songsection' as content of a songbook."""
from patacrep.content import Content, ContentError from patacrep.content import ContentItem, ContentError, ContentList, EmptyContentList
KEYWORDS = [ KEYWORDS = [
"songchapter", "songchapter",
"songsection", "songsection",
] ]
class SongSection(Content): class SongSection(ContentItem):
"""A songsection or songchapter.""" """A songsection or songchapter."""
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
@ -29,16 +29,19 @@ def parse(keyword, argument, contentlist, config):
- contentlist: a list of one string, which is the name of the section; - contentlist: a list of one string, which is the name of the section;
- config: configuration dictionary of the current songbook. - config: configuration dictionary of the current songbook.
""" """
if (keyword not in KEYWORDS) and (len(contentlist) != 1): try:
raise ContentError( if (keyword not in KEYWORDS) and (len(contentlist) != 1):
keyword, raise ContentError(
"Starred section names must have exactly one argument.", keyword,
) "Starred section names must have exactly one argument.",
return [SongSection(keyword, contentlist[0])] )
return ContentList([SongSection(keyword, contentlist[0])])
except ContentError as error:
return EmptyContentList(errors=[error])
CONTENT_PLUGINS = dict([ CONTENT_PLUGINS = dict([
(word, parse) (keyword, parse)
for word for keyword
in KEYWORDS in KEYWORDS
]) ])

7
patacrep/content/sorted.py

@ -9,7 +9,8 @@ import logging
import unidecode import unidecode
from patacrep import files from patacrep import files
from patacrep.content import ContentError, process_content from patacrep.content import ContentError, EmptyContentList
from patacrep.content import process_content
from patacrep.content.song import OnlySongsError from patacrep.content.song import OnlySongsError
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -85,11 +86,11 @@ def parse(keyword, config, argument, contentlist):
try: try:
songlist = process_content(contentlist, config) songlist = process_content(contentlist, config)
except OnlySongsError as error: except OnlySongsError as error:
raise ContentError(keyword, ( return EmptyContentList(errors=[ContentError(keyword, (
"Content list of this keyword can be only songs (or content " "Content list of this keyword can be only songs (or content "
"that result into songs), and the following are not:" + "that result into songs), and the following are not:" +
str(error.not_songs) str(error.not_songs)
)) ))])
return sorted(songlist, key=key_generator(sort)) return sorted(songlist, key=key_generator(sort))
CONTENT_PLUGINS = {'sorted': parse} CONTENT_PLUGINS = {'sorted': parse}

15
patacrep/content/tex.py

@ -5,11 +5,11 @@ import logging
import os import os
from patacrep import files, errors from patacrep import files, errors
from patacrep.content import Content from patacrep.content import ContentItem, ContentList, ContentError
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
class LaTeX(Content): class LaTeX(ContentItem):
"""Inclusion of LaTeX code""" """Inclusion of LaTeX code"""
def __init__(self, filename): def __init__(self, filename):
@ -35,7 +35,7 @@ def parse(keyword, argument, contentlist, config):
LOGGER.warning( LOGGER.warning(
"Useless 'tex' content: list of files to include is empty." "Useless 'tex' content: list of files to include is empty."
) )
filelist = [] filelist = ContentList()
basefolders = itertools.chain( basefolders = itertools.chain(
(path.fullpath for path in config['_songdir']), (path.fullpath for path in config['_songdir']),
files.iter_datadirs(config['_datadir']), files.iter_datadirs(config['_datadir']),
@ -51,11 +51,12 @@ def parse(keyword, argument, contentlist, config):
)) ))
break break
if not checked_file: if not checked_file:
LOGGER.warning( filelist.append_error(
"{} Compilation may fail later.".format( ContentError(
errors.notfound(filename, basefolders) keyword="tex",
message=errors.notfound(filename, basefolders),
)
) )
)
continue continue
filelist.append(LaTeX(checked_file)) filelist.append(LaTeX(checked_file))

14
patacrep/errors.py

@ -104,6 +104,20 @@ class ParsingError(SongbookError):
def __str__(self): def __str__(self):
return self.message return self.message
class SharedError(SongbookError):
"""Error that is meant to be shared to third party tools using patacrep."""
def __str__(self):
raise NotImplementedError()
@property
def __dict__(self):
return {
'type': self.__class__.__name__,
'message': str(self),
'full_message': str(self),
}
def notfound(filename, paths, message=None): def notfound(filename, paths, message=None):
"""Return a string saying that file was not found in paths.""" """Return a string saying that file was not found in paths."""
if message is None: if message is None:

2
patacrep/index.py

@ -46,7 +46,7 @@ def process_sxd(filename):
return idx return idx
class Index(object): class Index:
"""Title, author or scripture Index representation.""" """Title, author or scripture Index representation."""
def __init__(self, indextype): def __init__(self, indextype):

73
patacrep/latex/__init__.py

@ -8,10 +8,13 @@ will work on simple cases, but not on complex ones.
import logging import logging
from collections import OrderedDict from collections import OrderedDict
from patacrep import errors
from patacrep.latex.syntax import tex2plain, parse_song from patacrep.latex.syntax import tex2plain, parse_song
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
DEFAULT_LANGUAGE = "en_us"
BABEL_LANGUAGES = OrderedDict(( BABEL_LANGUAGES = OrderedDict((
('de_de', 'german'), ('de_de', 'german'),
('de_at', 'austrian'), ('de_at', 'austrian'),
@ -77,32 +80,76 @@ BABEL_LANGUAGES = OrderedDict((
# ('??_??', 'welsh'), # ('??_??', 'welsh'),
)) ))
def lang2babel(lang): class UnknownLanguage(errors.SharedError):
"""Return the language used by babel, corresponding to the language code""" """Error: Unknown language."""
def __init__(self, *, original, fallback, message):
super().__init__()
self.original = original
self.fallback = fallback
self.message = message
@property
def babel(self):
"""Return the fallback babel language."""
return BABEL_LANGUAGES[self.fallback]
def __str__(self):
return self.message
@property
def __dict__(self):
parent = vars(super())
parent.update({
'fallback': self.fallback,
'original': self.original,
})
return parent
def checklanguage(lang):
"""Check that `lang` is a known language.
Raise an :class:`UnknownLanguage` exception if not.
"""
# Exact match # Exact match
if lang.lower() in BABEL_LANGUAGES: if lang.lower() in BABEL_LANGUAGES:
return BABEL_LANGUAGES[lang.lower()] return lang.lower()
# Only language code is provided (e.g. 'fr') # Only language code is provided (e.g. 'fr')
for babel in BABEL_LANGUAGES: for babel in BABEL_LANGUAGES:
if babel.startswith(lang.lower()): if babel.startswith(lang.lower()):
return BABEL_LANGUAGES[babel] return babel
# A non existent country code is provided (e.g. 'fr_CD'). # A non existent country code is provided (e.g. 'fr_CD').
language = lang.lower().split("_")[0] language = lang.lower().split("_")[0]
for babel in BABEL_LANGUAGES: for babel in BABEL_LANGUAGES:
if babel.startswith(language): if babel.startswith(language):
LOGGER.error( raise UnknownLanguage(
"Unknown country code '{}'. Using default '{}' instead.".format( original=lang,
fallback=babel,
message="Unknown country code '{}'. Using default '{}' instead.".format(
lang, lang,
babel babel
) )
) )
return BABEL_LANGUAGES[babel]
# Error: no (exact or approximate) match found # Error: no (exact or approximate) match found
available = ", ".join(BABEL_LANGUAGES.keys()) available = ", ".join(BABEL_LANGUAGES.keys())
LOGGER.error( raise UnknownLanguage(
"Unknown language code '{}' (supported: {}). Using default 'english' instead.".format( original=lang,
lang, fallback=DEFAULT_LANGUAGE,
available message=(
) "Unknown language code '{}' (supported: {}). Using "
"default '{}' instead."
).format(
lang,
available,
DEFAULT_LANGUAGE,
)
) )
return 'english'
def lang2babel(lang):
"""Return the language used by babel, corresponding to the language code
Raises an `UnknownLanguage` exception if the `lang` argument is not known,
the :attr:`fallback` attribute of the exception being the existing
alternative language that can be used instead.
"""
return BABEL_LANGUAGES[checklanguage(lang)]

2
patacrep/latex/lexer.py

@ -72,7 +72,7 @@ class SimpleLexer:
@staticmethod @staticmethod
def t_error(token): def t_error(token):
"""Manage errors""" """Manage errors"""
LOGGER.error("Illegal character '{}'".format(token.value[0])) LOGGER.warning("Illegal character '{}'".format(token.value[0]))
token.lexer.skip(1) token.lexer.skip(1)
class SongLexer(SimpleLexer): class SongLexer(SimpleLexer):

17
patacrep/latex/syntax.py

@ -3,11 +3,10 @@
import logging import logging
import ply.yacc as yacc import ply.yacc as yacc
from patacrep.songs.syntax import Parser
from patacrep.latex.lexer import tokens, SimpleLexer, SongLexer
from patacrep.latex import ast from patacrep.latex import ast
from patacrep.errors import ParsingError
from patacrep.latex.detex import detex from patacrep.latex.detex import detex
from patacrep.latex.lexer import tokens, SimpleLexer, SongLexer
from patacrep.songs.syntax import Parser
LOGGER = logging.getLogger() LOGGER = logging.getLogger()
@ -126,20 +125,22 @@ class LatexParser(Parser):
else: else:
symbols[0] = [] symbols[0] = []
@staticmethod def p_dictionary(self, symbols):
def p_dictionary(symbols):
"""dictionary : identifier EQUAL braces dictionary_next """dictionary : identifier EQUAL braces dictionary_next
| identifier EQUAL error dictionary_next | identifier EQUAL error dictionary_next
| empty | empty
""" """
symbols[0] = {}
if len(symbols) == 2: if len(symbols) == 2:
symbols[0] = {} pass
elif isinstance(symbols[3], ast.Expression): elif isinstance(symbols[3], ast.Expression):
symbols[0] = {}
symbols[0][symbols[1]] = symbols[3] symbols[0][symbols[1]] = symbols[3]
symbols[0].update(symbols[4]) symbols[0].update(symbols[4])
else: else:
raise ParsingError("Do enclose arguments between braces.") self.error(
line=symbols.lexer.lineno,
message="Argument '{}' should be enclosed between braces.".format(symbols[1]),
)
@staticmethod @staticmethod
def p_identifier(symbols): def p_identifier(symbols):

37
patacrep/songs/__init__.py

@ -7,8 +7,10 @@ import os
import pickle import pickle
import re import re
from patacrep import errors as book_errors
from patacrep import files, encoding from patacrep import files, encoding
from patacrep.authors import process_listauthors from patacrep.authors import process_listauthors
from patacrep.songs import errors as song_errors
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -25,7 +27,7 @@ def cached_name(datadir, filename):
raise raise
return fullpath return fullpath
class DataSubpath(object): class DataSubpath:
"""A path divided in two path: a datadir, and its subpath. """A path divided in two path: a datadir, and its subpath.
- This object can represent either a file or directory. - This object can represent either a file or directory.
@ -79,7 +81,7 @@ class Song:
# Version format of cached song. Increment this number if we update # Version format of cached song. Increment this number if we update
# information stored in cache. # information stored in cache.
CACHE_VERSION = 3 CACHE_VERSION = 4
# List of attributes to cache # List of attributes to cache
cached_attributes = [ cached_attributes = [
@ -87,6 +89,7 @@ class Song:
"unprefixed_titles", "unprefixed_titles",
"cached", "cached",
"data", "data",
"errors",
"lang", "lang",
"authors", "authors",
"_filehash", "_filehash",
@ -109,8 +112,9 @@ class Song:
self.subpath = subpath self.subpath = subpath
self._filehash = None self._filehash = None
self.encoding = config['book']["encoding"] self.encoding = config['book']["encoding"]
self.default_lang = config['book']["lang"] self.lang = config['book']["lang"]
self.config = config self.config = config
self.errors = []
if self._cache_retrieved(): if self._cache_retrieved():
return return
@ -118,8 +122,7 @@ class Song:
# Data extraction from the latex song # Data extraction from the latex song
self.titles = [] self.titles = []
self.data = {} self.data = {}
self.cached = None self.cached = {}
self.lang = None
self._parse() self._parse()
# Post processing of data # Post processing of data
@ -174,16 +177,22 @@ class Song:
def _write_cache(self): def _write_cache(self):
"""If relevant, write a dumbed down version of self to the cache.""" """If relevant, write a dumbed down version of self to the cache."""
if self.use_cache: if not self.use_cache:
cached = {attr: getattr(self, attr) for attr in self.cached_attributes} return
pickle.dump( if self.errors:
cached, # As errors are exceptions, we cannot cache them because of a Python
open(self.cached_name, 'wb'), # bug. When this bug is fixed, we will cache errors.
protocol=-1 # https://bugs.python.org/issue1692335
) return
cached = {attr: getattr(self, attr) for attr in self.cached_attributes}
pickle.dump(
cached,
open(self.cached_name, 'wb'),
protocol=-1
)
def __repr__(self): def __str__(self):
return repr((self.titles, self.data, self.fullpath)) return str(self.fullpath)
def render(self, *args, **kwargs): def render(self, *args, **kwargs):
"""Return the code rendering this song. """Return the code rendering this song.

68
patacrep/songs/chordpro/__init__.py

@ -4,15 +4,16 @@ import logging
import operator import operator
import os import os
from jinja2 import Environment, FileSystemLoader, contextfunction, ChoiceLoader from jinja2 import Environment, FileSystemLoader, ChoiceLoader
from jinja2 import contextfunction
import jinja2 import jinja2
from patacrep import encoding, files, pkg_datapath from patacrep import encoding, files, pkg_datapath
from patacrep.songs import Song from patacrep.songs import Song
from patacrep.songs.chordpro.syntax import parse_song from patacrep.songs.chordpro.syntax import parse_song
from patacrep.songs.errors import FileNotFound, SongUnknownLanguage
from patacrep.templates import Renderer from patacrep.templates import Renderer
from patacrep.latex import lang2babel from patacrep.latex import lang2babel, UnknownLanguage
from patacrep.files import path2posix
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -20,6 +21,10 @@ def sort_directive_argument(directives):
"""Sort directives by their argument.""" """Sort directives by their argument."""
return sorted(directives, key=operator.attrgetter("argument")) return sorted(directives, key=operator.attrgetter("argument"))
DEFAULT_FILTERS = {
'sortargs': sort_directive_argument,
}
class ChordproSong(Song): class ChordproSong(Song):
"""Chordpro song parser""" """Chordpro song parser"""
# pylint: disable=abstract-method # pylint: disable=abstract-method
@ -32,12 +37,22 @@ class ChordproSong(Song):
song = parse_song(song.read(), self.fullpath) song = parse_song(song.read(), self.fullpath)
self.authors = song.authors self.authors = song.authors
self.titles = song.titles self.titles = song.titles
self.lang = song.get_data_argument('language', self.default_lang) self.lang = song.get_data_argument('language', self.lang)
self.data = song.meta self.data = song.meta
self.errors = [error(song=self) for error in song.error_builders]
self.cached = { self.cached = {
'song': song, 'song': song,
} }
def _filters(self):
"""Return additional jinja2 filters."""
filters = DEFAULT_FILTERS.copy()
filters.update({
'search_image': self.search_image,
'search_partition': self.search_partition,
})
return filters
def render(self, template="song"): # pylint: disable=arguments-differ def render(self, template="song"): # pylint: disable=arguments-differ
context = { context = {
'lang': self.lang, 'lang': self.lang,
@ -51,11 +66,7 @@ class ChordproSong(Song):
jinjaenv = Environment(loader=FileSystemLoader( jinjaenv = Environment(loader=FileSystemLoader(
self.iter_datadirs("templates", "songs", "chordpro", self.output_language) self.iter_datadirs("templates", "songs", "chordpro", self.output_language)
)) ))
jinjaenv.filters['search_image'] = self.search_image jinjaenv.filters.update(self._filters())
jinjaenv.filters['search_partition'] = self.search_partition
jinjaenv.filters['lang2babel'] = lang2babel
jinjaenv.filters['sortargs'] = sort_directive_argument
jinjaenv.filters['path2posix'] = path2posix
try: try:
return Renderer( return Renderer(
@ -106,22 +117,49 @@ class Chordpro2LatexSong(ChordproSong):
try: try:
return os.path.join("scores", super().search_partition(filename)) return os.path.join("scores", super().search_partition(filename))
except FileNotFoundError: except FileNotFoundError:
LOGGER.warning( message = "Song '{}' (datadir '{}'): Score '{}' not found.".format(
"Song '%s' (datadir '%s'): Score '%s' not found.", self.subpath, self.datadir, filename
self.subpath, self.datadir, filename,
) )
self.errors.append(FileNotFound(self, filename))
LOGGER.warning(message)
return None return None
def search_image(self, filename): def search_image(self, filename):
try: try:
return os.path.join("img", super().search_image(filename)) return os.path.join("img", super().search_image(filename))
except FileNotFoundError: except FileNotFoundError:
LOGGER.warning( message = "Song '{}' (datadir '{}'): Image '{}' not found.".format(
"Song '%s' (datadir '%s'): Image '%s' not found.", self.subpath, self.datadir, filename
self.subpath, self.datadir, filename,
) )
self.errors.append(FileNotFound(self, filename))
LOGGER.warning(message)
return None return None
def _filters(self):
parent = super()._filters()
parent.update({
'lang2babel': self.lang2babel,
})
return parent
def lang2babel(self, lang):
"""Return the LaTeX babel code corresponding to `lang`.
Add an error to the list of errors if argument is invalid.
"""
try:
return lang2babel(lang)
except UnknownLanguage as error:
new_error = SongUnknownLanguage(
self,
error.original,
error.fallback,
error.message,
)
LOGGER.warning(new_error)
self.errors.append(new_error)
return error.babel
class Chordpro2ChordproSong(ChordproSong): class Chordpro2ChordproSong(ChordproSong):
"""Render chordpro song to chordpro code""" """Render chordpro song to chordpro code"""

17
patacrep/songs/chordpro/ast.py

@ -3,8 +3,11 @@
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
from collections import OrderedDict from collections import OrderedDict
import functools
import logging import logging
from patacrep.songs import errors
LOGGER = logging.getLogger() LOGGER = logging.getLogger()
def _indent(string): def _indent(string):
@ -223,7 +226,7 @@ class Song(AST):
"tag": "add_cumulative", "tag": "add_cumulative",
} }
def __init__(self, filename, directives): def __init__(self, filename, directives, *, error_builders=None):
super().__init__() super().__init__()
self.content = [] self.content = []
self.meta = OrderedDict() self.meta = OrderedDict()
@ -231,6 +234,10 @@ class Song(AST):
self._titles = [] self._titles = []
self._subtitles = [] self._subtitles = []
self.filename = filename self.filename = filename
if error_builders is None:
self.error_builders = []
else:
self.error_builders = error_builders
for directive in directives: for directive in directives:
self.add(directive) self.add(directive)
@ -261,7 +268,13 @@ class Song(AST):
# Add a metadata directive. Some of them are added using special # Add a metadata directive. Some of them are added using special
# methods listed in ``METADATA_ADD``. # methods listed in ``METADATA_ADD``.
if data.keyword not in AVAILABLE_DIRECTIVES: if data.keyword not in AVAILABLE_DIRECTIVES:
LOGGER.warning("Ignoring unknown directive '{}'.".format(data.keyword)) message = "Ignoring unknown directive '{}'.".format(data.keyword)
LOGGER.warning("Song {}, line {}: {}".format(self.filename, data.lineno, message))
self.error_builders.append(functools.partial(
errors.SongSyntaxError,
line=data.lineno,
message=message,
))
if data.keyword in self.METADATA_ADD: if data.keyword in self.METADATA_ADD:
getattr(self, self.METADATA_ADD[data.keyword])(data) getattr(self, self.METADATA_ADD[data.keyword])(data)
else: else:

19
patacrep/songs/chordpro/lexer.py

@ -1,8 +1,12 @@
"""ChordPro lexer""" """ChordPro lexer"""
import functools
import logging import logging
import ply.lex as lex import ply.lex as lex
from patacrep.songs import errors
LOGGER = logging.getLogger() LOGGER = logging.getLogger()
#pylint: disable=invalid-name #pylint: disable=invalid-name
@ -85,6 +89,7 @@ class ChordProLexer:
def __init__(self, *, filename=None): def __init__(self, *, filename=None):
self.__class__.lexer = lex.lex(module=self) self.__class__.lexer = lex.lex(module=self)
self.error_builders = []
self.filename = filename self.filename = filename
# Define a rule so we can track line numbers # Define a rule so we can track line numbers
@ -135,14 +140,20 @@ class ChordProLexer:
def error(self, token, more=""): def error(self, token, more=""):
"""Display error message, and skip illegal token.""" """Display error message, and skip illegal token."""
message = "Line {line}: Illegal character '{char}'{more}.".format( message = "Illegal character '{char}'{more}.".format(
line=token.lexer.lineno,
char=token.value[0], char=token.value[0],
more=more, more=more,
) )
self.error_builders.append(functools.partial(
errors.SongSyntaxError,
line=token.lexer.lineno,
message=message,
))
if self.filename is not None: if self.filename is not None:
message = "File {}: {}".format(self.filename, message) message = "Song {}, line {}: {}".format(self.filename, token.lexer.lineno, message)
LOGGER.error(message) else:
message = "Line {}: {}".format(token.lexer.lineno, message)
LOGGER.warning(message)
token.lexer.skip(1) token.lexer.skip(1)
def t_error(self, token): def t_error(self, token):

34
patacrep/songs/chordpro/syntax.py

@ -5,9 +5,10 @@ import re
import ply.yacc as yacc import ply.yacc as yacc
from patacrep.songs.syntax import Parser from patacrep.content import ContentError
from patacrep.songs.chordpro import ast from patacrep.songs.chordpro import ast
from patacrep.songs.chordpro.lexer import tokens, ChordProLexer from patacrep.songs.chordpro.lexer import tokens, ChordProLexer
from patacrep.songs.syntax import Parser
LOGGER = logging.getLogger() LOGGER = logging.getLogger()
@ -28,22 +29,28 @@ class ChordproParser(Parser):
write_tables=0, write_tables=0,
) )
def parse(self, content, *, lexer): def parse(self, content):
"""Parse file """Parse file
This is a shortcut to `yacc.yacc(...).parse()`. The arguments are This is a shortcut to `yacc.yacc(...).parse()`. The arguments are
transmitted to this method. transmitted to this method.
""" """
lexer = ChordProLexer(filename=self.filename).lexer lexer = ChordProLexer(filename=self.filename)
ast.AST.lexer = lexer ast.AST.lexer = lexer.lexer
return self.parser.parse(content, lexer=lexer) parsed = self.parser.parse(content, lexer=lexer.lexer)
parsed.error_builders.extend(lexer.error_builders)
return parsed
def p_song(self, symbols): def p_song(self, symbols):
"""song : block song """song : block song
| empty | empty
""" """
if len(symbols) == 2: if len(symbols) == 2:
symbols[0] = ast.Song(self.filename, self._directives) symbols[0] = ast.Song(
self.filename,
directives=self._directives,
error_builders=self._errors,
)
else: else:
symbols[0] = symbols[2].add(symbols[1]) symbols[0] = symbols[2].add(symbols[1])
@ -187,10 +194,12 @@ class ChordproParser(Parser):
else: else:
symbols[0] = None symbols[0] = None
@staticmethod def p_line_error(self, symbols):
def p_line_error(symbols):
"""line_error : error directive""" """line_error : error directive"""
LOGGER.error("Directive can only be preceded or followed by spaces") self.error(
line=symbols.lexer.lineno,
message="Directive can only be preceded or followed by spaces",
)
symbols[0] = ast.Line() symbols[0] = ast.Line()
@staticmethod @staticmethod
@ -317,10 +326,7 @@ class ChordproParser(Parser):
def parse_song(content, filename=None): def parse_song(content, filename=None):
"""Parse song and return its metadata.""" """Parse song and return its metadata."""
parser = ChordproParser(filename) parser = ChordproParser(filename)
parsed_content = parser.parse( parsed_content = parser.parse(content)
content,
lexer=ChordProLexer(filename=filename).lexer,
)
if parsed_content is None: if parsed_content is None:
raise SyntaxError('Fatal error during song parsing: {}'.format(filename)) raise ContentError(message='Fatal error during song parsing.')
return parsed_content return parsed_content

88
patacrep/songs/errors.py

@ -0,0 +1,88 @@
"""Errors in song definition (syntax errors, and so on)"""
from patacrep.errors import SharedError
class SongError(SharedError):
"""Generic song error"""
# pylint: disable=too-few-public-methods
def __init__(self, song, message):
super().__init__()
self.song = song
self.message = message
def __str__(self):
return "{}: {}".format(self._human_song(), self.message)
def _human_song(self):
return "Datadir '{}', song '{}'".format(
self.song.datadir,
self.song.subpath,
)
@property
def __dict__(self):
parent = vars(super())
parent.update({
'datadir': self.song.datadir,
'subpath': self.song.subpath,
'message': self.message,
'full_message': str(self),
})
return parent
class SongSyntaxError(SongError):
"""Syntax error"""
# pylint: disable=too-few-public-methods
def __init__(self, song, line, message):
super().__init__(song, message)
#: Line of error. May be `None` if irrelevant.
self.line = line
def __str__(self):
if self.line is not None:
return "{}, line {}: {}".format(self._human_song(), self.line, self.message)
else:
return "{}: {}".format(self._human_song(), self.message)
@property
def __dict__(self):
parent = vars(super())
if self.line is not None:
parent.update({
'line': self.line,
})
return parent
class FileNotFound(SongError):
"""File not found error"""
def __init__(self, song, filename):
super().__init__(song, "File '{}' not found.".format(filename))
self.filename = filename
@property
def __dict__(self):
parent = vars(super())
parent.update({
'filename': self.filename,
})
return parent
class SongUnknownLanguage(SongError):
"""Song language is not known."""
def __init__(self, song, original, fallback, message):
super().__init__(song, message)
self.original = original
self.fallback = fallback
@property
def __dict__(self):
parent = vars(super())
parent.update({
'original': self.original,
'fallback': self.fallback,
})
return parent

21
patacrep/songs/syntax.py

@ -1,7 +1,10 @@
"""Generic parsing classes and methods""" """Generic parsing classes and methods"""
import functools
import logging import logging
from patacrep.songs import errors
LOGGER = logging.getLogger() LOGGER = logging.getLogger()
class Parser: class Parser:
@ -10,6 +13,7 @@ class Parser:
def __init__(self): def __init__(self):
self.filename = "" # Will be overloaded self.filename = "" # Will be overloaded
self._errors = []
@staticmethod @staticmethod
def __find_column(token): def __find_column(token):
@ -21,7 +25,13 @@ class Parser:
return column return column
def error(self, *, line=None, column=None, message=""): def error(self, *, line=None, column=None, message=""):
"""Display an error message""" """Record and display an error message"""
self._errors.append(functools.partial(
errors.SongSyntaxError,
line=line,
message=message,
))
coordinates = [] coordinates = []
if line is not None: if line is not None:
coordinates.append("line {}".format(line)) coordinates.append("line {}".format(line))
@ -35,18 +45,17 @@ class Parser:
else: else:
text += "." text += "."
if self.filename is None: if self.filename is None:
LOGGER.error(text) LOGGER.warning(text)
else: else:
LOGGER.error("File {}: {}".format(self.filename, text)) LOGGER.warning("Song {}: {}".format(self.filename, text))
def p_error(self, token): def p_error(self, token):
"""Manage parsing errors.""" """Manage parsing errors."""
if token is None: if token is None:
self.error( self.error(message="Unexpected end of file.")
message="Unexpected end of file.",
)
else: else:
self.error( self.error(
message="Syntax error",
line=token.lineno, line=token.lineno,
column=self.__find_column(token), column=self.__find_column(token),
) )

55
patacrep/templates.py

@ -1,5 +1,6 @@
"""Template for .tex generation settings and utilities""" """Template for .tex generation settings and utilities"""
import logging
import re import re
import yaml import yaml
@ -10,9 +11,11 @@ from jinja2.ext import Extension
from jinja2.meta import find_referenced_templates as find_templates from jinja2.meta import find_referenced_templates as find_templates
from patacrep import errors, files, utils from patacrep import errors, files, utils
from patacrep.latex import lang2babel from patacrep.latex import lang2babel, UnknownLanguage
import patacrep.encoding import patacrep.encoding
LOGGER = logging.getLogger(__name__)
_LATEX_SUBS = ( _LATEX_SUBS = (
(re.compile(r'\\'), r'\\textbackslash'), (re.compile(r'\\'), r'\\textbackslash'),
(re.compile(r'([{}_#%&$])'), r'\\\1'), (re.compile(r'([{}_#%&$])'), r'\\\1'),
@ -43,6 +46,18 @@ _VARIABLE_REGEXP = re.compile(
""", """,
re.VERBOSE|re.DOTALL) re.VERBOSE|re.DOTALL)
def _escape_tex(value):
'''Escape TeX special characters'''
newval = value
for pattern, replacement in _LATEX_SUBS:
newval = pattern.sub(replacement, newval)
return newval
DEFAULT_FILTERS = {
"escape_tex": _escape_tex,
"iter_datadirs": files.iter_datadirs,
"path2posix": files.path2posix,
}
class VariablesExtension(Extension): class VariablesExtension(Extension):
"""Extension to jinja2 to silently ignore variable block. """Extension to jinja2 to silently ignore variable block.
@ -60,19 +75,12 @@ class VariablesExtension(Extension):
return nodes.Const("") # pylint: disable=no-value-for-parameter return nodes.Const("") # pylint: disable=no-value-for-parameter
def _escape_tex(value):
'''Escape TeX special characters'''
newval = value
for pattern, replacement in _LATEX_SUBS:
newval = pattern.sub(replacement, newval)
return newval
class Renderer: class Renderer:
"""Render a template to a LaTeX file.""" """Render a template to a LaTeX file."""
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
def __init__(self, template, jinjaenv, encoding=None): def __init__(self, template, jinjaenv, encoding=None):
self.errors = []
self.encoding = encoding self.encoding = encoding
self.jinjaenv = jinjaenv self.jinjaenv = jinjaenv
self.jinjaenv.block_start_string = '(*' self.jinjaenv.block_start_string = '(*'
@ -82,14 +90,35 @@ class Renderer:
self.jinjaenv.comment_start_string = '(% comment %)' self.jinjaenv.comment_start_string = '(% comment %)'
self.jinjaenv.comment_end_string = '(% endcomment %)' self.jinjaenv.comment_end_string = '(% endcomment %)'
self.jinjaenv.line_comment_prefix = '%!' self.jinjaenv.line_comment_prefix = '%!'
self.jinjaenv.filters['escape_tex'] = _escape_tex
self.jinjaenv.trim_blocks = True self.jinjaenv.trim_blocks = True
self.jinjaenv.lstrip_blocks = True self.jinjaenv.lstrip_blocks = True
self.jinjaenv.filters["path2posix"] = files.path2posix # Fill default filters
self.jinjaenv.filters["iter_datadirs"] = files.iter_datadirs for key, value in self.filters().items():
self.jinjaenv.filters["lang2babel"] = lang2babel if key not in self.jinjaenv.filters:
self.jinjaenv.filters[key] = value
self.template = self.jinjaenv.get_template(template) self.template = self.jinjaenv.get_template(template)
def filters(self):
"""Return a dictionary of jinja2 filters."""
filters = DEFAULT_FILTERS.copy()
filters.update({
"lang2babel": self.lang2babel,
})
return filters
def lang2babel(self, lang):
"""Return the LaTeX babel code corresponding to `lang`.
Add an error to the list of errors if argument is invalid.
"""
try:
return lang2babel(lang)
except UnknownLanguage as error:
error.message = "Songbook: {}".format(error.message)
LOGGER.warning(error.message)
self.errors.append(error)
return error.babel
class TexBookRenderer(Renderer): class TexBookRenderer(Renderer):
"""Tex renderer for the whole songbook""" """Tex renderer for the whole songbook"""

0
test/test_content/datadir/custom_list.json → test/test_content/datadir/songs/custom_list.json

3
test/test_song/errors/invalid_content.csg.source

@ -1,3 +0,0 @@
{soc}
Chorus
{eoc

20
test/test_song/test_parser.py

@ -11,6 +11,7 @@ from pkg_resources import resource_filename
from patacrep import files from patacrep import files
from patacrep.encoding import open_read from patacrep.encoding import open_read
from patacrep.build import config_model from patacrep.build import config_model
from patacrep.songs import errors
from .. import logging_reduced from .. import logging_reduced
from .. import dynamic # pylint: disable=unused-import from .. import dynamic # pylint: disable=unused-import
@ -99,14 +100,15 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest):
cls._create_test(base, in_format, out_format), cls._create_test(base, in_format, out_format),
) )
with cls.chdir('errors'): if os.path.isdir("errors"):
for source in sorted(glob.glob('*.*.source')): with cls.chdir('errors'):
[*base, in_format, _] = source.split('.') for source in sorted(glob.glob('*.*.source')):
base = '.'.join(base) [*base, in_format, _] = source.split('.')
yield ( base = '.'.join(base)
"test_{}_{}_failure".format(base, in_format), yield (
cls._create_failure(base, in_format), "test_{}_{}_failure".format(base, in_format),
) cls._create_failure(base, in_format),
)
@classmethod @classmethod
def _create_test(cls, base, in_format, out_format): def _create_test(cls, base, in_format, out_format):
@ -128,7 +130,7 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest):
sourcename = "{}.{}.source".format(base, in_format) sourcename = "{}.{}.source".format(base, in_format)
with self.chdir('errors'): with self.chdir('errors'):
parser = self.song_plugins[out_format][in_format] parser = self.song_plugins[out_format][in_format]
self.assertRaises(SyntaxError, parser, sourcename, self.config) self.assertRaises(errors.SongSyntaxError, parser, sourcename, self.config)
test_parse_failure.__doc__ = ( test_parse_failure.__doc__ = (
"Test that '{base}' parsing fails." "Test that '{base}' parsing fails."

18
test/test_songbook/content.sb

@ -0,0 +1,18 @@
book:
pictures: yes
datadir: content_datadir
lang: en
chords:
repeatchords: no
diagramreminder: all
content: [
["section", "Test of section"],
["sorted"],
["songsection", "Test of song section"],
["cwd(content_datadir/content)",
"song.csg", "song.tsg",
["tex", "foo.tex"]
],
["include", "include.sbc"]
]

159
test/test_songbook/content.tex.control

@ -0,0 +1,159 @@
%% Automatically generated document.
%% You may edit this file but all changes will be overwritten.
%% If you want to change this document, have a look at
%% the templating system.
%%
%% Generated using Songbook <http://www.patacrep.com>
\makeatletter
\def\input@path{ %
{@TEST_FOLDER@/content_datadir/latex/} %
{@TEST_FOLDER@/latex/} %
{@DATA_FOLDER@/latex/} %
}
\makeatother
\documentclass[
]{article}
\usepackage[
chorded,
pictures,
diagram,
guitar,
]{patacrep}
\usepackage{lmodern}
\PassOptionsToPackage{english}{babel}
\PassOptionsToPackage{english}{babel}
\usepackage[english]{babel}
\lang{english}
\usepackage{graphicx}
\graphicspath{ %
{@TEST_FOLDER@/content_datadir/} %
{@TEST_FOLDER@/} %
{@DATA_FOLDER@/} %
}
\makeatletter
\@ifpackageloaded{hyperref}{}{
\usepackage{url}
\newcommand{\phantomsection}{}
\newcommand{\hyperlink}[2]{#2}
\newcommand{\href}[2]{\expandafter\url\expandafter{#1}}
}
\makeatother
\usepackage{chords}
\title{Guitar songbook}
\author{The Patacrep Team}
\newindex{titleidx}{content_title}
\newauthorindex{authidx}{content_auth}
\authignoreword{unknown}
\authbyword{by}
\authsepword{and}
\notenamesout{A}{B}{C}{D}{E}{F}{G}
\begin{document}
\maketitle
\showindex{\songindexname}{titleidx}
\showindex{\authorindexname}{authidx}
% list of chords
\ifchorded
\ifdiagram
\phantomsection
\addcontentsline{toc}{section}{\chordlistname}
\chords
\fi
\fi
\phantomsection
\addcontentsline{toc}{section}{\songlistname}
\section{Test of section}
\begin{songs}{titleidx,authidx}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% songs/./song.tsg
\import{@TEST_FOLDER@/content_datadir/songs/}{song.tsg}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% songs/./song.csg
\selectlanguage{english}
\beginsong{This is a song}[
by={
},
]
\begin{verse}
Foo
\end{verse}
\endsong
\end{songs}
\songsection{Test of song section}
\begin{songs}{titleidx,authidx}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% content_datadir/content/song.csg
\selectlanguage{english}
\beginsong{Yet another song}[
by={
},
]
\begin{verse}
Baz
\end{verse}
\endsong
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% content_datadir/content/song.tsg
\import{@TEST_FOLDER@/content_datadir/content/}{song.tsg}
\end{songs}
\input{content_datadir/content/foo.tex}
\section{This is an included section}
\end{document}

1
test/test_songbook/content_datadir/content/foo.tex

@ -0,0 +1 @@
This is a \LaTeX{} file.

5
test/test_songbook/content_datadir/content/inter.isg

@ -0,0 +1,5 @@
\begin{intersong}
\sortassong{}[by={The Patacrep Team}]
This is another example of an intersong.
\end{intersong}

2
test/test_songbook/content_datadir/content/song.csg

@ -0,0 +1,2 @@
{title : Yet another song}
Baz

3
test/test_songbook/content_datadir/content/song.tsg

@ -0,0 +1,3 @@
\beginsong{One last song}
Tagada
\endsong

1
test/test_songbook/content_datadir/songs/include.sbc

@ -0,0 +1 @@
[["section", "This is an included section"]]

5
test/test_songbook/content_datadir/songs/inter.isg

@ -0,0 +1,5 @@
\begin{intersong}
\sortassong{}[by={The Patacrep Team}]
This is an example of an intersong.
\end{intersong}

3
test/test_songbook/content_datadir/songs/song.csg

@ -0,0 +1,3 @@
{title : This is a song}
Foo

3
test/test_songbook/content_datadir/songs/song.tsg

@ -0,0 +1,3 @@
\beginsong{This is another song}
Bar.
\endsong

2
texlive_packages.txt

@ -6,6 +6,7 @@ babel-italian
babel-latin babel-latin
babel-portuges babel-portuges
babel-spanish babel-spanish
ctablestack
etoolbox etoolbox
fancybox fancybox
framed framed
@ -16,6 +17,7 @@ mptopdf
ms ms
pgf pgf
tipa tipa
unicode-data
url url
xcolor xcolor
xstring xstring

Loading…
Cancel
Save