Browse Source

Merge pull request #176 from patacrep/errors

Add an `errors` attribute to songs, compiling the song errors

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

3
examples/example-test.sb

@ -5,9 +5,10 @@
"lilypond",
"pictures"
],
"lang": "ERROR",
"booktype" : "chorded",
"template" : "patacrep.tex",
"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}
{ title : Error}
{subtitle: A chordpro file with many errors}
@ -12,4 +12,5 @@ Bla [H]bla
{fo#: bar}
{image: foo}
{partition: bar}

94
patacrep/build.py

@ -31,7 +31,7 @@ GENERATED_EXTENSIONS = [
# pylint: disable=too-few-public-methods
class Songbook(object):
class Songbook:
"""Represent a songbook (.sb) file.
- Low level: provide a Python representation of the values stored in the
@ -40,22 +40,24 @@ class Songbook(object):
"""
def __init__(self, raw_songbook, basename):
super().__init__()
self._raw_config = raw_songbook
self.config = raw_songbook
self.basename = basename
self._errors = list()
self._config = dict()
# Some special keys have their value processed.
self._set_datadir()
def _set_datadir(self):
"""Set the default values for datadir"""
try:
if isinstance(self.config['datadir'], str):
self.config['datadir'] = [self.config['datadir']]
if isinstance(self._raw_config['datadir'], str):
self._raw_config['datadir'] = [self._raw_config['datadir']]
except KeyError: # No datadir in the raw_songbook
self.config['datadir'] = [os.path.abspath('.')]
self._raw_config['datadir'] = [os.path.abspath('.')]
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):
abs_datadir.append(os.path.abspath(path))
else:
@ -63,10 +65,10 @@ class Songbook(object):
"Ignoring non-existent datadir '{}'.".format(path)
)
self.config['datadir'] = abs_datadir
self.config['_songdir'] = [
self._raw_config['datadir'] = abs_datadir
self._raw_config['_songdir'] = [
DataSubpath(path, 'songs')
for path in self.config['datadir']
for path in self._raw_config['datadir']
]
def write_tex(self, output):
@ -76,42 +78,74 @@ class Songbook(object):
- output: a file object, in which the file will be written.
"""
# Updating configuration
config = DEFAULT_CONFIG.copy()
config.update(self.config)
self._config = DEFAULT_CONFIG.copy()
self._config.update(self._raw_config)
renderer = TexBookRenderer(
config['template'],
config['datadir'],
config['lang'],
config['encoding'],
self._config['template'],
self._config['datadir'],
self._config['lang'],
self._config['encoding'],
)
config.update(renderer.get_variables())
config.update(self.config)
self._config.update(renderer.get_variables())
self._config.update(self._raw_config)
config['_compiled_authwords'] = authors.compile_authwords(
copy.deepcopy(config['authwords'])
self._config['_compiled_authwords'] = authors.compile_authwords(
copy.deepcopy(self._config['authwords'])
)
# Loading custom plugins
config['_content_plugins'] = files.load_plugins(
datadirs=config.get('datadir', []),
self._config['_content_plugins'] = files.load_plugins(
datadirs=self._config.get('datadir', []),
root_modules=['content'],
keyword='CONTENT_PLUGINS',
)
config['_song_plugins'] = files.load_plugins(
datadirs=config.get('datadir', []),
self._config['_song_plugins'] = files.load_plugins(
datadirs=self._config.get('datadir', []),
root_modules=['songs'],
keyword='SONG_RENDERERS',
)['tsg']
# Configuration set
config['render'] = content.render
config['content'] = content.process_content(
config.get('content', []),
config,
self._config['render'] = content.render
self._config['content'] = content.process_content(
self._config.get('content', []),
self._config,
)
config['filename'] = output.name[:-4]
self._config['filename'] = output.name[:-4]
renderer.render_tex(output, config)
renderer.render_tex(output, self._config)
self._errors.extend(renderer.errors)
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):
"""Tell if lilypond is part of the bookoptions"""
@ -125,7 +159,7 @@ def _log_pipe(pipe):
break
LOGGER.debug(line.strip())
class SongbookBuilder(object):
class SongbookBuilder:
"""Provide methods to compile a songbook."""
# 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
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.
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
@ -27,8 +27,8 @@ A parser is a function which takes as arguments:
- 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).
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
@ -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
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.
Content is a perfectly valid class, but instances of it will not generate
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 Content.
More documentation in the docstring of ContentItem.
"""
@ -74,13 +74,13 @@ import sys
import jinja2
from patacrep import files
from patacrep.errors import SongbookError
from patacrep.errors import SharedError
LOGGER = logging.getLogger(__name__)
EOL = '\n'
#pylint: disable=no-self-use
class Content(object):
class ContentItem:
"""Content item. Will render to something in the .tex file.
The current jinja2.runtime.Context is passed to all function defined
@ -101,8 +101,8 @@ class Content(object):
# Arguments
- __previous: the songbook.content.Content object of the previous item.
- __context: see Content() documentation.
- __previous: the songbook.content.ContentItem object of the previous item.
- __context: see ContentItem() documentation.
# Return
@ -121,15 +121,81 @@ class Content(object):
"""Return the string to end a block."""
return ""
class ContentError(SongbookError):
class ContentError(SharedError):
"""Error in a content plugin."""
def __init__(self, keyword, message):
def __init__(self, keyword=None, message=None):
super().__init__()
self.keyword = keyword
self.message = message
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
def render(context, content):
@ -138,15 +204,15 @@ def render(context, content):
Arguments:
- context: the jinja2.runtime.context of the current template
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().
"""
rendered = ""
previous = None
last = None
for elem in content:
if not isinstance(elem, Content):
LOGGER.error("Ignoring bad content item '{}'.".format(elem))
if not isinstance(elem, ContentItem):
LOGGER.warning("Ignoring bad content item '{}'.".format(elem))
continue
last = elem
@ -157,23 +223,23 @@ def render(context, content):
rendered += elem.render(context) + EOL
previous = elem
if isinstance(last, Content):
if last is not None:
rendered += last.end_block(context) + EOL
return rendered
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:
- 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
Return: a list of ContentItem objects, corresponding to the content to be
included in the .tex file.
"""
contentlist = []
contentlist = ContentList()
plugins = config.get('_content_plugins', {})
keyword_re = re.compile(r'^ *(?P<keyword>[\w\*]*) *(\((?P<argument>.*)\))? *$')
if not content:
@ -184,10 +250,12 @@ def process_content(content, config=None):
try:
match = keyword_re.match(elem[0]).groupdict()
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'])
if keyword not in plugins:
raise ContentError(keyword, "Unknown content type.")
contentlist.append_error(ContentError(keyword, "Unknown content type."))
continue
contentlist.extend(plugins[keyword](
keyword,
argument=argument,

2
patacrep/content/cwd.py

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

23
patacrep/content/include.py

@ -6,10 +6,9 @@ songs in JSON format.
import json
import os
import sys
import logging
from patacrep.content import process_content, ContentError
from patacrep.content import process_content, ContentError, ContentList
from patacrep import encoding, errors, files
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'.
"""
for filepath in files.iter_datadirs(datadirs, path):
for filepath in files.iter_datadirs(datadirs, "songs", path):
if os.path.exists(filepath):
return filepath
# File not found
@ -38,10 +37,14 @@ def parse(keyword, config, argument, contentlist):
- argument: None;
- contentlist: a list of file paths to be included.
"""
new_contentlist = []
new_contentlist = ContentList()
for path in contentlist:
filepath = load_from_datadirs(path, config.get('datadir', []))
try:
filepath = load_from_datadirs(path, config.get('datadir', []))
except ContentError as error:
new_contentlist.append_error(error)
continue
content_file = None
try:
with encoding.open_read(
@ -50,12 +53,14 @@ def parse(keyword, config, argument, contentlist):
) as content_file:
new_content = json.load(content_file)
except Exception as error: # pylint: disable=broad-except
LOGGER.error(error)
LOGGER.error("Error while loading file '{}'.".format(filepath))
sys.exit(1)
new_contentlist.append_error(ContentError(
keyword="include",
message="Error while loading file '{}': {}".format(filepath, error),
))
continue
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()
return new_contentlist

23
patacrep/content/section.py

@ -1,6 +1,6 @@
"""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 = [
"part",
@ -13,7 +13,7 @@ KEYWORDS = [
]
FULL_KEYWORDS = KEYWORDS + ["{}*".format(word) for word in KEYWORDS]
class Section(Content):
class Section(ContentItem):
"""A LaTeX section."""
# pylint: disable=too-few-public-methods
@ -41,14 +41,17 @@ def parse(keyword, argument, contentlist, config):
and short) 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)]
try:
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 ContentList([Section(keyword, *contentlist)])
except ContentError as error:
return EmptyContentList(errors=[error])
CONTENT_PLUGINS = dict([

39
patacrep/content/song.py

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

23
patacrep/content/songsection.py

@ -1,13 +1,13 @@
"""Allow 'songchapter' and 'songsection' as content of a songbook."""
from patacrep.content import Content, ContentError
from patacrep.content import ContentItem, ContentError, ContentList, EmptyContentList
KEYWORDS = [
"songchapter",
"songsection",
]
class SongSection(Content):
class SongSection(ContentItem):
"""A songsection or songchapter."""
# 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;
- 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])]
try:
if (keyword not in KEYWORDS) and (len(contentlist) != 1):
raise ContentError(
keyword,
"Starred section names must have exactly one argument.",
)
return ContentList([SongSection(keyword, contentlist[0])])
except ContentError as error:
return EmptyContentList(errors=[error])
CONTENT_PLUGINS = dict([
(word, parse)
for word
(keyword, parse)
for keyword
in KEYWORDS
])

7
patacrep/content/sorted.py

@ -9,7 +9,8 @@ import logging
import unidecode
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
LOGGER = logging.getLogger(__name__)
@ -85,11 +86,11 @@ def parse(keyword, config, argument, contentlist):
try:
songlist = process_content(contentlist, config)
except OnlySongsError as error:
raise ContentError(keyword, (
return EmptyContentList(errors=[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}

15
patacrep/content/tex.py

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

14
patacrep/errors.py

@ -104,6 +104,20 @@ class ParsingError(SongbookError):
def __str__(self):
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):
"""Return a string saying that file was not found in paths."""
if message is None:

2
patacrep/index.py

@ -46,7 +46,7 @@ def process_sxd(filename):
return idx
class Index(object):
class Index:
"""Title, author or scripture Index representation."""
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
from collections import OrderedDict
from patacrep import errors
from patacrep.latex.syntax import tex2plain, parse_song
LOGGER = logging.getLogger(__name__)
DEFAULT_LANGUAGE = "en_us"
BABEL_LANGUAGES = OrderedDict((
('de_de', 'german'),
('de_at', 'austrian'),
@ -77,32 +80,76 @@ BABEL_LANGUAGES = OrderedDict((
# ('??_??', 'welsh'),
))
def lang2babel(lang):
"""Return the language used by babel, corresponding to the language code"""
class UnknownLanguage(errors.SharedError):
"""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
if lang.lower() in BABEL_LANGUAGES:
return BABEL_LANGUAGES[lang.lower()]
return lang.lower()
# Only language code is provided (e.g. 'fr')
for babel in BABEL_LANGUAGES:
if babel.startswith(lang.lower()):
return BABEL_LANGUAGES[babel]
return babel
# A non existent country code is provided (e.g. 'fr_CD').
language = lang.lower().split("_")[0]
for babel in BABEL_LANGUAGES:
if babel.startswith(language):
LOGGER.error(
"Unknown country code '{}'. Using default '{}' instead.".format(
raise UnknownLanguage(
original=lang,
fallback=babel,
message="Unknown country code '{}'. Using default '{}' instead.".format(
lang,
babel
)
)
return BABEL_LANGUAGES[babel]
# Error: no (exact or approximate) match found
available = ", ".join(BABEL_LANGUAGES.keys())
LOGGER.error(
"Unknown language code '{}' (supported: {}). Using default 'english' instead.".format(
lang,
available
)
raise UnknownLanguage(
original=lang,
fallback=DEFAULT_LANGUAGE,
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
def t_error(token):
"""Manage errors"""
LOGGER.error("Illegal character '{}'".format(token.value[0]))
LOGGER.warning("Illegal character '{}'".format(token.value[0]))
token.lexer.skip(1)
class SongLexer(SimpleLexer):

17
patacrep/latex/syntax.py

@ -3,11 +3,10 @@
import logging
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.errors import ParsingError
from patacrep.latex.detex import detex
from patacrep.latex.lexer import tokens, SimpleLexer, SongLexer
from patacrep.songs.syntax import Parser
LOGGER = logging.getLogger()
@ -126,20 +125,22 @@ class LatexParser(Parser):
else:
symbols[0] = []
@staticmethod
def p_dictionary(symbols):
def p_dictionary(self, symbols):
"""dictionary : identifier EQUAL braces dictionary_next
| identifier EQUAL error dictionary_next
| empty
"""
symbols[0] = {}
if len(symbols) == 2:
symbols[0] = {}
pass
elif isinstance(symbols[3], ast.Expression):
symbols[0] = {}
symbols[0][symbols[1]] = symbols[3]
symbols[0].update(symbols[4])
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
def p_identifier(symbols):

37
patacrep/songs/__init__.py

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

68
patacrep/songs/chordpro/__init__.py

@ -4,15 +4,16 @@ import logging
import operator
import os
from jinja2 import Environment, FileSystemLoader, contextfunction, ChoiceLoader
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
from jinja2 import contextfunction
import jinja2
from patacrep import encoding, files, pkg_datapath
from patacrep.songs import Song
from patacrep.songs.chordpro.syntax import parse_song
from patacrep.songs.errors import FileNotFound, SongUnknownLanguage
from patacrep.templates import Renderer
from patacrep.latex import lang2babel
from patacrep.files import path2posix
from patacrep.latex import lang2babel, UnknownLanguage
LOGGER = logging.getLogger(__name__)
@ -20,6 +21,10 @@ def sort_directive_argument(directives):
"""Sort directives by their argument."""
return sorted(directives, key=operator.attrgetter("argument"))
DEFAULT_FILTERS = {
'sortargs': sort_directive_argument,
}
class ChordproSong(Song):
"""Chordpro song parser"""
# pylint: disable=abstract-method
@ -32,12 +37,22 @@ class ChordproSong(Song):
song = parse_song(song.read(), self.fullpath)
self.authors = song.authors
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.errors = [error(song=self) for error in song.error_builders]
self.cached = {
'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
context = {
'lang': self.lang,
@ -51,11 +66,7 @@ class ChordproSong(Song):
jinjaenv = Environment(loader=FileSystemLoader(
self.iter_datadirs("templates", "songs", "chordpro", self.output_language)
))
jinjaenv.filters['search_image'] = self.search_image
jinjaenv.filters['search_partition'] = self.search_partition
jinjaenv.filters['lang2babel'] = lang2babel
jinjaenv.filters['sortargs'] = sort_directive_argument
jinjaenv.filters['path2posix'] = path2posix
jinjaenv.filters.update(self._filters())
try:
return Renderer(
@ -106,22 +117,49 @@ class Chordpro2LatexSong(ChordproSong):
try:
return os.path.join("scores", super().search_partition(filename))
except FileNotFoundError:
LOGGER.warning(
"Song '%s' (datadir '%s'): Score '%s' not found.",
self.subpath, self.datadir, filename,
message = "Song '{}' (datadir '{}'): Score '{}' not found.".format(
self.subpath, self.datadir, filename
)
self.errors.append(FileNotFound(self, filename))
LOGGER.warning(message)
return None
def search_image(self, filename):
try:
return os.path.join("img", super().search_image(filename))
except FileNotFoundError:
LOGGER.warning(
"Song '%s' (datadir '%s'): Image '%s' not found.",
self.subpath, self.datadir, filename,
message = "Song '{}' (datadir '{}'): Image '{}' not found.".format(
self.subpath, self.datadir, filename
)
self.errors.append(FileNotFound(self, filename))
LOGGER.warning(message)
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):
"""Render chordpro song to chordpro code"""

17
patacrep/songs/chordpro/ast.py

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

19
patacrep/songs/chordpro/lexer.py

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

34
patacrep/songs/chordpro/syntax.py

@ -5,9 +5,10 @@ import re
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.lexer import tokens, ChordProLexer
from patacrep.songs.syntax import Parser
LOGGER = logging.getLogger()
@ -28,22 +29,28 @@ class ChordproParser(Parser):
write_tables=0,
)
def parse(self, content, *, lexer):
def parse(self, content):
"""Parse file
This is a shortcut to `yacc.yacc(...).parse()`. The arguments are
transmitted to this method.
"""
lexer = ChordProLexer(filename=self.filename).lexer
ast.AST.lexer = lexer
return self.parser.parse(content, lexer=lexer)
lexer = ChordProLexer(filename=self.filename)
ast.AST.lexer = lexer.lexer
parsed = self.parser.parse(content, lexer=lexer.lexer)
parsed.error_builders.extend(lexer.error_builders)
return parsed
def p_song(self, symbols):
"""song : block song
| empty
"""
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:
symbols[0] = symbols[2].add(symbols[1])
@ -187,10 +194,12 @@ class ChordproParser(Parser):
else:
symbols[0] = None
@staticmethod
def p_line_error(symbols):
def p_line_error(self, symbols):
"""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()
@staticmethod
@ -317,10 +326,7 @@ class ChordproParser(Parser):
def parse_song(content, filename=None):
"""Parse song and return its metadata."""
parser = ChordproParser(filename)
parsed_content = parser.parse(
content,
lexer=ChordProLexer(filename=filename).lexer,
)
parsed_content = parser.parse(content)
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

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

55
patacrep/templates.py

@ -1,5 +1,6 @@
"""Template for .tex generation settings and utilities"""
import logging
import re
import json
@ -9,9 +10,11 @@ from jinja2.ext import Extension
from jinja2.meta import find_referenced_templates as find_templates
from patacrep import errors, files
from patacrep.latex import lang2babel
from patacrep.latex import lang2babel, UnknownLanguage
import patacrep.encoding
LOGGER = logging.getLogger(__name__)
_LATEX_SUBS = (
(re.compile(r'\\'), r'\\textbackslash'),
(re.compile(r'([{}_#%&$])'), r'\\\1'),
@ -42,6 +45,18 @@ _VARIABLE_REGEXP = re.compile(
""",
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):
"""Extension to jinja2 to silently ignore variable block.
@ -59,19 +74,12 @@ class VariablesExtension(Extension):
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:
"""Render a template to a LaTeX file."""
# pylint: disable=too-few-public-methods
def __init__(self, template, jinjaenv, encoding=None):
self.errors = []
self.encoding = encoding
self.jinjaenv = jinjaenv
self.jinjaenv.block_start_string = '(*'
@ -81,14 +89,35 @@ class Renderer:
self.jinjaenv.comment_start_string = '(% comment %)'
self.jinjaenv.comment_end_string = '(% endcomment %)'
self.jinjaenv.line_comment_prefix = '%!'
self.jinjaenv.filters['escape_tex'] = _escape_tex
self.jinjaenv.trim_blocks = True
self.jinjaenv.lstrip_blocks = True
self.jinjaenv.filters["path2posix"] = files.path2posix
self.jinjaenv.filters["iter_datadirs"] = files.iter_datadirs
self.jinjaenv.filters["lang2babel"] = lang2babel
# Fill default filters
for key, value in self.filters().items():
if key not in self.jinjaenv.filters:
self.jinjaenv.filters[key] = value
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):
"""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.songs import DEFAULT_CONFIG
from patacrep.encoding import open_read
from patacrep.songs import errors
from .. import logging_reduced
from .. import dynamic # pylint: disable=unused-import
@ -97,14 +98,15 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest):
cls._create_test(base, in_format, out_format),
)
with cls.chdir('errors'):
for source in sorted(glob.glob('*.*.source')):
[*base, in_format, _] = source.split('.')
base = '.'.join(base)
yield (
"test_{}_{}_failure".format(base, in_format),
cls._create_failure(base, in_format),
)
if os.path.isdir("errors"):
with cls.chdir('errors'):
for source in sorted(glob.glob('*.*.source')):
[*base, in_format, _] = source.split('.')
base = '.'.join(base)
yield (
"test_{}_{}_failure".format(base, in_format),
cls._create_failure(base, in_format),
)
@classmethod
def _create_test(cls, base, in_format, out_format):
@ -126,7 +128,7 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest):
sourcename = "{}.{}.source".format(base, in_format)
with self.chdir('errors'):
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 that '{base}' parsing fails."

13
test/test_songbook/content.sb

@ -0,0 +1,13 @@
{
"datadir": ["content_datadir"],
"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"]
]
}

156
test/test_songbook/content.tex.control

@ -0,0 +1,156 @@
%% 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,
diagram,
pictures,
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}
\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
Loading…
Cancel
Save