diff --git a/patacrep/authors.py b/patacrep/authors.py index 3bdb6b9a..84613a1a 100644 --- a/patacrep/authors.py +++ b/patacrep/authors.py @@ -164,17 +164,21 @@ def processauthors(authors_string, after=None, ignore=None, sep=None): For example, we are processing: # processauthors( - # "Lyrics by William Blake (from Milton, 1808), - music by Hubert Parry (1916), - and sung by The Royal\ Choir~of~Nowhere - (just here to show you how processing is done)", + # [ + # " + # Lyrics by William Blake (from Milton, 1808), + # music by Hubert Parry (1916), + # and sung by The Royal\ Choir~of~Nowhere + # (just here to show you how processing is done) + # ", + # ], # after = ["by"], # ignore = ["anonymous"], # sep = [re.compile('^(.*) and (.*)$')], # ) - The "authors_string" string is processed as: + The "authors_string" is processed as: 1) First, parenthesis (and its content) are removed. # "Lyrics by William Blake, music by Hubert Parry, @@ -220,3 +224,13 @@ def processauthors(authors_string, after=None, ignore=None, sep=None): ignore) ) ] + +def process_listauthors(authors_list, after=None, ignore=None, sep=None): + """Process a list of authors, and return the list of resulting authors.""" + authors = [] + for sublist in [ + processauthors(string, after, ignore, sep) + for string in authors_list + ]: + authors.extend(sublist) + return authors diff --git a/patacrep/build.py b/patacrep/build.py index baf5626b..a575f9a4 100644 --- a/patacrep/build.py +++ b/patacrep/build.py @@ -10,7 +10,7 @@ from subprocess import Popen, PIPE, call from patacrep import __DATADIR__, authors, content, errors, files from patacrep.index import process_sxd -from patacrep.templates import TexRenderer +from patacrep.templates import TexBookRenderer from patacrep.songs import DataSubpath LOGGER = logging.getLogger(__name__) @@ -87,7 +87,7 @@ class Songbook(object): # Updating configuration config = DEFAULT_CONFIG.copy() config.update(self.config) - renderer = TexRenderer( + renderer = TexBookRenderer( config['template'], config['datadir'], config['lang'], @@ -103,19 +103,13 @@ class Songbook(object): # Loading custom plugins config['_content_plugins'] = files.load_plugins( datadirs=config.get('datadir', []), - subdir=['content'], - variable='CONTENT_PLUGINS', - error=( - "File {filename}: Keyword '{keyword}' is already used. Ignored." - ), + root_modules=['content'], + keyword='CONTENT_PLUGINS', ) - config['_file_plugins'] = files.load_plugins( + config['_song_plugins'] = files.load_plugins( datadirs=config.get('datadir', []), - subdir=['songs'], - variable='FILE_PLUGINS', - error=( - "File {filename}: Keyword '{keyword}' is already used. Ignored." - ), + root_modules=['songs'], + keyword='SONG_PARSERS', ) # Configuration set @@ -225,8 +219,9 @@ class SongbookBuilder(object): stdin=PIPE, stdout=PIPE, stderr=PIPE, + env=os.environ, universal_newlines=True, - env=os.environ) + ) except Exception as error: LOGGER.debug(error) raise errors.LatexCompilationError(self.basename) diff --git a/patacrep/content/__init__.py b/patacrep/content/__init__.py index 07441c85..d92dc99b 100755 --- a/patacrep/content/__init__.py +++ b/patacrep/content/__init__.py @@ -174,7 +174,6 @@ def process_content(content, config=None): """ contentlist = [] plugins = config.get('_content_plugins', {}) - keyword_re = re.compile(r'^ *(?P\w*) *(\((?P.*)\))? *$') if not content: content = [["song"]] diff --git a/patacrep/content/song.py b/patacrep/content/song.py index 252c5dc8..e54f58c2 100755 --- a/patacrep/content/song.py +++ b/patacrep/content/song.py @@ -1,15 +1,41 @@ """Plugin to include songs to the songbook.""" import glob +import jinja2 import logging import os -from patacrep.content import process_content, ContentError +from patacrep.content import process_content, ContentError, Content from patacrep import files, errors -from patacrep.songs import Song LOGGER = logging.getLogger(__name__) +class SongRenderer(Content): + """Render a song in as a tex code.""" + + def __init__(self, song): + super().__init__() + self.song = song + + def begin_new_block(self, previous, __context): + """Return a boolean stating if a new block is to be created.""" + return not isinstance(previous, SongRenderer) + + def begin_block(self, context): + """Return the string to begin a block.""" + indexes = context.resolve("indexes") + if isinstance(indexes, jinja2.runtime.Undefined): + indexes = "" + return r'\begin{songs}{%s}' % indexes + + def end_block(self, __context): + """Return the string to end a block.""" + return r'\end{songs}' + + def render(self, context): + """Return the string that will render the song.""" + return self.song.tex(output=context['filename']) + #pylint: disable=unused-argument def parse(keyword, argument, contentlist, config): """Parse data associated with keyword 'song'. @@ -23,15 +49,14 @@ def parse(keyword, argument, contentlist, config): Return a list of Song() instances. """ + plugins = config['_song_plugins'] if '_languages' not in config: config['_languages'] = set() songlist = [] - plugins = config.get('_file_plugins', {}) for songdir in config['_songdir']: if contentlist: break contentlist = files.recursive_find(songdir.fullpath, plugins.keys()) - for elem in contentlist: before = len(songlist) for songdir in config['_songdir']: @@ -40,17 +65,22 @@ def parse(keyword, argument, contentlist, config): with files.chdir(songdir.datadir): for filename in glob.iglob(os.path.join(songdir.subpath, elem)): LOGGER.debug('Parsing file "{}"…'.format(filename)) - try: - renderer = plugins[filename.split('.')[-1]] - except KeyError: - LOGGER.warning(( - 'I do not know how to parse file "{}". Ignored.' - ).format(os.path.join(songdir.datadir, filename)) - ) + extension = filename.split(".")[-1] + if extension not in plugins: + LOGGER.warning( + '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()]), + ) continue - song = renderer(songdir.datadir, filename, config) - songlist.append(song) - config["_languages"].update(song.languages) + renderer = SongRenderer(plugins[extension]( + songdir.datadir, + filename, + config, + )) + songlist.append(renderer) + config["_languages"].update(renderer.song.languages) if len(songlist) > before: break if len(songlist) == before: @@ -69,8 +99,8 @@ CONTENT_PLUGINS = {'song': parse} class OnlySongsError(ContentError): "A list that should contain only songs also contain other type of content." def __init__(self, not_songs): - super(OnlySongsError, self).__init__() self.not_songs = not_songs + super().__init__('song', str(self)) def __str__(self): return ( @@ -89,7 +119,7 @@ def process_songs(content, config=None): item for item in contentlist - if not isinstance(item, Song) + if not isinstance(item, SongRenderer) ] if not_songs: raise OnlySongsError(not_songs) diff --git a/patacrep/content/sorted.py b/patacrep/content/sorted.py index 57fa4f8d..b3bbe40a 100755 --- a/patacrep/content/sorted.py +++ b/patacrep/content/sorted.py @@ -40,8 +40,9 @@ def key_generator(sort): - sort: the list of keys used to sort. """ - def ordered_song_keys(song): + def ordered_song_keys(songrenderer): """Return the list of values used to sort the song.""" + song = songrenderer.song songkey = [] for key in sort: if key == "@title": diff --git a/patacrep/data/examples/example-all.sb b/patacrep/data/examples/example-all.sb index 0b1252aa..ca35b622 100644 --- a/patacrep/data/examples/example-all.sb +++ b/patacrep/data/examples/example-all.sb @@ -7,6 +7,7 @@ ], "booktype" : "chorded", "lang" : "french", +"encoding": "utf8", "authwords" : { "sep" : ["and", "et"] }, diff --git a/patacrep/data/examples/songs/greensleeves.sgc b/patacrep/data/examples/songs/greensleeves.sgc new file mode 100644 index 00000000..a039cc74 --- /dev/null +++ b/patacrep/data/examples/songs/greensleeves.sgc @@ -0,0 +1,51 @@ +{language : english} +{columns : 2} +{ title : Greensleeves} +{ title : Un sous titre} +{artist: Traditionnel} +{artist: Prénom Nom} +{cover : traditionnel } +{album :Angleterre} + +{partition : greensleeves.ly} + + +A[Am]las, my love, ye [G]do me wrong +To [Am]cast me oft dis[E]curteously +And [Am]I have loved [G]you so long +De[Am]lighting [E]in your [Am]companie + +{start_of_chorus} + [C]Greensleeves was [G]all my joy + [Am]Greensleeves was [E]my delight + [C]Greensleeves was my [G]heart of gold + And [Am]who but [E]Ladie [Am]Greensleeves +{end_of_chorus} + +I [Am]have been ready [G]at your hand +To [Am]grant what ever [E]you would crave +I [Am]have both waged [G]life and land +Your [Am]love and [E]good will [Am]for to have + +I [Am]bought thee kerchers [G]to thy head +That [Am]were wrought fine and [E]gallantly +I [Am]kept thee both at [G]boord and bed +Which [Am]cost my [E]purse well [Am]favouredly + +I [Am]bought thee peticotes [G]of the best +The [Am]cloth so fine as [E]fine might be +I [Am]gave thee jewels [G]for thy chest +And [Am]all this [E]cost I [Am]spent on thee + +{c:test of comment} + +{gc: test of guitar comment} + +{image: traditionnel} + +Thy [Am]smock of silke, both [G]faire and white +With [Am]gold embrodered [E]gorgeously +Thy [Am]peticote of [G]sendall right +And [Am]this I [E]bought thee [Am]gladly + + diff --git a/patacrep/files.py b/patacrep/files.py index 23ca0920..a82c50b5 100644 --- a/patacrep/files.py +++ b/patacrep/files.py @@ -1,18 +1,17 @@ """File system utilities.""" from contextlib import contextmanager -import glob +import fnmatch import importlib import logging import os import posixpath import re -import sys LOGGER = logging.getLogger(__name__) def recursive_find(root_directory, extensions): - """Recursively find files with some extension, from a root_directory. + """Recursively find files with the given extensions, from a root_directory. Return a list of files matching those conditions. @@ -71,29 +70,35 @@ def chdir(path): else: yield -def load_plugins(datadirs, subdir, variable, error): - """Load all content plugins, and return a dictionary of those plugins. +def load_plugins(datadirs, root_modules, keyword): + """Load all plugins, and return a dictionary of those plugins. A plugin is a .py file, submodule of `subdir`, located in one of the - directories of `datadirs`. It contains a dictionary `variable`. The return + directories of `datadirs`. It contains a dictionary `keyword`. The return value is the union of the dictionaries of the loaded plugins. Arguments: - - datadirs: list of directories (as strings) in which files has to be - searched. - - subdir: modules (as a list of strings) files has to be submodules of - (e.g. if `subdir` is `['first', 'second']`, search files are of the form - `first/second/*.py`. - - variable: Name of the variable holding the dictionary. - - error: Error message raised if a key appears several times. + - datadirs: List of directories in which plugins are to be searched. + - root_modules: the submodule in which plugins are to be searched, as a + list of modules (e.g. ["some", "deep", "module"] for + "some.deep.module"). + - keyword: attribute containing plugin information. + + Return value: a dictionary where: + - keys are the keywords ; + - values are functions triggered when this keyword is met. """ + # pylint: disable=star-args plugins = {} directory_list = ( [ - os.path.join(datadir, "python", *subdir) #pylint: disable=star-args + os.path.join(datadir, "python", *root_modules) for datadir in datadirs ] - + [os.path.dirname(__file__)] + + [os.path.join( + os.path.dirname(__file__), + *root_modules + )] ) for directory in directory_list: if not os.path.exists(directory): @@ -102,31 +107,26 @@ def load_plugins(datadirs, subdir, variable, error): directory ) continue - sys.path.append(directory) - for name in glob.glob(os.path.join(directory, *(subdir + ['*.py']))): - if name.endswith(".py") and os.path.basename(name) != "__init__.py": - if directory == os.path.dirname(__file__): - plugin = importlib.import_module( - 'patacrep.{}.{}'.format( - ".".join(subdir), - os.path.basename(name[:-len('.py')]) - ) - ) + for (dirpath, __ignored, filenames) in os.walk(directory): + modules = ["patacrep"] + root_modules + if os.path.relpath(dirpath, directory) != ".": + modules.extend(os.path.relpath(dirpath, directory).split("/")) + for name in filenames: + if name == "__init__.py": + modulename = [] + elif name.endswith(".py"): + modulename = [name[:-len('.py')]] else: - plugin = importlib.import_module( - os.path.basename(name[:-len('.py')]) - ) - for (key, value) in getattr(plugin, variable, {}).items(): - if key in plugins: - LOGGER.warning( - error.format( - filename=relpath(name), - key=key, - ) + continue + plugin = importlib.import_module(".".join(modules + modulename)) + if hasattr(plugin, keyword): + for (key, value) in getattr(plugin, keyword).items(): + if key in plugins: + LOGGER.warning( + "File %s: Keyword '%s' is already used. Ignored.", + relpath(os.path.join(dirpath, name)), + key, ) - continue - plugins[key] = value - del sys.path[-1] + continue + plugins[key] = value return plugins - - diff --git a/patacrep/index.py b/patacrep/index.py index 6a4300a2..8540d433 100644 --- a/patacrep/index.py +++ b/patacrep/index.py @@ -19,7 +19,6 @@ EOL = "\n" KEYWORD_PATTERN = re.compile(r"^%(\w+)\s?(.*)$", re.LOCALE) FIRST_LETTER_PATTERN = re.compile(r"^(?:\{?\\\w+\}?)*[^\w]*(\w)", re.LOCALE) - def process_sxd(filename): """Parse sxd file. @@ -162,7 +161,11 @@ class Index(object): def entry_to_str(self, key, entry): """Return the LaTeX code corresponding to the entry.""" - return (r'\idxentry{{{0}}}{{{1}}}' + EOL).format( + return r"""\idxentry{{ + {0} + }}{{ + {1} + }}""".format( self.key_to_str(key), r'\\'.join([self.ref_to_str(ref) for ref in entry]), ) @@ -182,13 +185,13 @@ class Index(object): ] string = r'\begin{idxblock}{' + letter + '}' + EOL for key in sorted(entries, key=sortkey): - string += self.entry_to_str(key, entries[key]['entries']) - string += r'\end{idxblock}' + EOL + string += " " + self.entry_to_str(key, entries[key]['entries']) + string += EOL + r'\end{idxblock}' return string def entries_to_str(self): """Return the LaTeX code corresponding to the index.""" string = "" for letter in sorted(self.data.keys()): - string += self.idxblock_to_str(letter, self.data[letter]) + string += self.idxblock_to_str(letter, self.data[letter]) + EOL return string diff --git a/patacrep/latex/__init__.py b/patacrep/latex/__init__.py index 140fa51e..2c6db73a 100644 --- a/patacrep/latex/__init__.py +++ b/patacrep/latex/__init__.py @@ -1,19 +1,8 @@ -"""Very simple LaTeX parser +"""Dumb and very very incomplete LaTeX parser. This module uses an LALR parser to try to parse LaTeX code. LaTeX language *cannot* be parsed by an LALR parser, so this is a very simple attemps, which will work on simple cases, but not on complex ones. """ -from patacrep.latex.syntax import tex2plain -from patacrep.latex.syntax import parsesong as syntax_parsesong -from patacrep import encoding - -def parsesong(path, fileencoding=None): - """Return a dictonary of data read from the latex file `path`. - - """ - with encoding.open_read(path, encoding=fileencoding) as songfile: - data = syntax_parsesong(songfile.read(), path) - data['@path'] = path - return data +from patacrep.latex.syntax import tex2plain, parse_song diff --git a/patacrep/latex/syntax.py b/patacrep/latex/syntax.py index 896d8442..aff3a885 100644 --- a/patacrep/latex/syntax.py +++ b/patacrep/latex/syntax.py @@ -245,12 +245,17 @@ def tex2plain(string): ) ) -def parsesong(string, filename=None): - """Parse song and return its metadata.""" +def parse_song(content, filename=None): + """Parse some LaTeX code, expected to be a song. + + Arguments: + - content: the code to parse. + - filename: the name of file where content was read from. Used only to + display error messages. + """ return detex( silent_yacc(module=Parser(filename)).parse( - string, + content, lexer=SongLexer().lexer, ).metadata ) - diff --git a/patacrep/songs/__init__.py b/patacrep/songs/__init__.py index df5514bb..53da8d48 100644 --- a/patacrep/songs/__init__.py +++ b/patacrep/songs/__init__.py @@ -8,7 +8,8 @@ import os import pickle import re -from patacrep.authors import processauthors +from patacrep.authors import process_listauthors +from patacrep import files, encoding from patacrep.content import Content LOGGER = logging.getLogger(__name__) @@ -99,6 +100,7 @@ class Song(Content): self.fullpath = os.path.join(datadir, subpath) self.datadir = datadir self.encoding = config["encoding"] + self.config = config if datadir: # Only songs in datadirs are cached @@ -123,15 +125,15 @@ class Song(Content): self.fullpath )) - # Default values - self.data = {} + # Data extraction from the latex song self.titles = [] - self.languages = [] - self.authors = [] + self.data = {} + self.cached = None + self.parse(config) - # Parsing and data processing - self.parse() + # Post processing of data self.datadir = datadir + self.subpath = subpath self.unprefixed_titles = [ unprefixed_title( title, @@ -140,17 +142,12 @@ class Song(Content): for title in self.titles ] - self.subpath = subpath - self.authors = processauthors( + self.authors = process_listauthors( self.authors, **config["_compiled_authwords"] ) # Cache management - - #: Special attribute to allow plugins to store cached data - self.cached = None - self._version = self.CACHE_VERSION self._write_cache() @@ -169,32 +166,16 @@ class Song(Content): def __repr__(self): return repr((self.titles, self.data, self.fullpath)) - def begin_new_block(self, previous, __context): - """Return a boolean stating if a new block is to be created.""" - return not isinstance(previous, Song) + def tex(self, output): # pylint: disable=no-self-use, unused-argument + """Return the LaTeX code rendering this song. - def begin_block(self, context): - """Return the string to begin a block.""" - indexes = context.resolve("indexes") - if isinstance(indexes, jinja2.runtime.Undefined): - indexes = "" - return r'\begin{songs}{%s}' % indexes - - def end_block(self, __context): - """Return the string to end a block.""" - return r'\end{songs}' - - def render(self, __context): - """Returns the TeX code rendering the song. - - This function is to be defined by subclasses. + Arguments: + - output: Name of the output file. """ - return '' - - def parse(self): - """Parse file `self.fullpath`. + raise NotImplementedError() - This function is to be defined by subclasses. + def parse(self, config): # pylint: disable=no-self-use + """Parse song. It set the following attributes: @@ -208,10 +189,7 @@ class Song(Content): - cached: additional data that will be cached. Thus, data stored in this attribute must be picklable. """ - self.data = {} - self.titles = [] - self.languages = [] - self.authors = [] + raise NotImplementedError() def unprefixed_title(title, prefixes): """Remove the first prefix of the list in the beginning of title (if any). @@ -221,4 +199,3 @@ def unprefixed_title(title, prefixes): if match: return match.group(2) return title - diff --git a/patacrep/songs/chordpro/__init__.py b/patacrep/songs/chordpro/__init__.py new file mode 100644 index 00000000..5574c3dc --- /dev/null +++ b/patacrep/songs/chordpro/__init__.py @@ -0,0 +1,49 @@ +"""Chordpro parser""" + +from jinja2 import Environment, FileSystemLoader +import pkg_resources +import os + +from patacrep import encoding, files +from patacrep.songs import Song +from patacrep.songs.chordpro.syntax import parse_song +from patacrep.templates import TexRenderer + +class ChordproSong(Song): + """Chordpros song parser.""" + + def parse(self, config): + """Parse content, and return the dictinory of song data.""" + with encoding.open_read(self.fullpath, encoding=self.encoding) as song: + song = parse_song(song.read(), self.fullpath) + self.authors = song.authors + self.titles = song.titles + self.languages = song.get_directives('language') + self.data = dict([meta.as_tuple for meta in song.meta]) + self.cached = { + 'song': song, + } + + def tex(self, output): + context = { + 'language': self.cached['song'].get_directive('language', self.config['lang']), + 'columns': self.cached['song'].get_directive('columns', 1), + "path": files.relpath(self.fullpath, os.path.dirname(output)), + "titles": r"\\".join(self.titles), + "authors": ", ".join(["{} {}".format(name[1], name[0]) for name in self.authors]), + "metadata": self.data, + "beginsong": self.cached['song'].meta_beginsong(), + "content": self.cached['song'].content, + } + return TexRenderer( + template="chordpro.tex", + encoding='utf8', + texenv=Environment(loader=FileSystemLoader(os.path.join( + os.path.abspath(pkg_resources.resource_filename(__name__, 'data')), + 'latex' + ))), + ).template.render(context) + +SONG_PARSERS = { + 'sgc': ChordproSong, + } diff --git a/patacrep/songs/chordpro/ast.py b/patacrep/songs/chordpro/ast.py new file mode 100644 index 00000000..47ffb8df --- /dev/null +++ b/patacrep/songs/chordpro/ast.py @@ -0,0 +1,410 @@ +"""Abstract Syntax Tree for ChordPro code.""" + +# pylint: disable=too-few-public-methods + +import functools +import logging +import os + +LOGGER = logging.getLogger() + +def _indent(string): + """Return and indented version of argument.""" + return "\n".join([" {}".format(line) for line in string.split('\n')]) + +#: List of properties that are to be displayed in the flow of the song (not as +#: metadata at the beginning or end of song. +INLINE_PROPERTIES = { + "partition", + "comment", + "guitar_comment", + "image", + } + +#: List of properties that are listed in the `\beginsong` LaTeX directive. +BEGINSONG_PROPERTIES = { + "album", + "copyright", + "cov", + "vcov", + "tag", + } + +#: Some directive have alternative names. For instance `{title: Foo}` and `{t: +#: Foo}` are equivalent. +DIRECTIVE_SHORTCUTS = { + "t": "title", + "st": "subtitle", + "a": "album", + "by": "artist", + "c": "comment", + "gc": "guitar_comment", + "cover": "cov", + "vcover": "vcov", + } + +def directive_name(text): + """Return name of the directive, considering eventual shortcuts.""" + return DIRECTIVE_SHORTCUTS.get(text, text) + + +class AST: + """Generic object representing elements of the song.""" + _template = None + inline = False + + def template(self, extension): + """Return the template to be used to render this object.""" + if self._template is None: + LOGGER.warning("No template defined for {}.".format(self.__class__)) + base = "error" + else: + base = self._template + return "content_{}.{}".format(base, extension) + +class Line(AST): + """A line is a sequence of (possibly truncated) words, spaces and chords.""" + + _template = "line" + + def __init__(self): + super().__init__() + self.line = [] + + def prepend(self, data): + """Add an object at the beginning of line.""" + self.line.insert(0, data) + return self + + def __str__(self): + return "".join([str(item) for item in self.line]) + + def strip(self): + """Remove spaces at the beginning and end of line.""" + while True: + if not self.line: + return self + if isinstance(self.line[0], Space): + del self.line[0] + continue + if isinstance(self.line[-1], Space): + del self.line[-1] + continue + return self + +class LineElement(AST): + """Something present on a line.""" + pass + +class Word(LineElement): + """A chunk of word.""" + _template = "word" + + def __init__(self, value): + super().__init__() + self.value = value + + def __str__(self): + return self.value + +class Space(LineElement): + """A space between words""" + _template = "space" + + def __init__(self): + super().__init__() + + def __str__(self): + return " " + +class Chord(LineElement): + """A chord.""" + + _template = "chord" + + def __init__(self, value): + super().__init__() + self.value = value + + def __str__(self): + return "[{}]".format(self.value) + +class Verse(AST): + """A verse (or bridge, or chorus)""" + _template = "verse" + type = "verse" + inline = True + + def __init__(self): + super().__init__() + self.lines = [] + + def prepend(self, data): + """Add data at the beginning of verse.""" + self.lines.insert(0, data) + return self + + def __str__(self): + return '{{start_of_{type}}}\n{content}\n{{end_of_{type}}}'.format( + type=self.type, + content=_indent("\n".join([str(line) for line in self.lines])), + ) + +class Chorus(Verse): + """Chorus""" + type = 'chorus' + +class Bridge(Verse): + """Bridge""" + type = 'bridge' + +class Song(AST): + r"""A song + + Attributes: + - content: the song content, as a list of objects `foo` such that + `foo.inline` is True. + - titles: The list of titles + - language: The language (if set), None otherwise + - authors: The list of authors + - meta_beginsong: The list of directives that are to be set in the + `\beginsong{}` LaTeX directive. + - meta: Every other metadata. + """ + + #: Some directives are added to the song using special methods. + METADATA_TYPE = { + "title": "add_title", + "subtitle": "add_subtitle", + "artist": "add_author", + "key": "add_key", + } + + #: Some directives have to be processed before being considered. + PROCESS_DIRECTIVE = { + "cov": "_process_relative", + "partition": "_process_relative", + "image": "_process_relative", + } + + def __init__(self, filename): + super().__init__() + self.content = [] + self.meta = [] + self._authors = [] + self._titles = [] + self._subtitles = [] + self._keys = [] + self.filename = filename + + def add(self, data): + """Add an element to the song""" + if isinstance(data, Directive): + # Some directives are preprocessed + name = directive_name(data.keyword) + if name in self.PROCESS_DIRECTIVE: + data = getattr(self, self.PROCESS_DIRECTIVE[name])(data) + + if data is None: + # New line + if not (self.content and isinstance(self.content[0], Newline)): + self.content.insert(0, Newline()) + elif isinstance(data, Line): + # Add a new line, maybe in the current verse. + if not (self.content and isinstance(self.content[0], Verse)): + self.content.insert(0, Verse()) + self.content[0].prepend(data.strip()) + elif data.inline: + # Add an object in the content of the song. + self.content.insert(0, data) + elif isinstance(data, Directive): + # Add a metadata directive. Some of them are added using special + # methods listed in ``METADATA_TYPE``. + name = directive_name(data.keyword) + if name in self.METADATA_TYPE: + getattr(self, self.METADATA_TYPE[name])(*data.as_tuple) + else: + self.meta.append(data) + else: + raise Exception() + return self + + def str_meta(self): + """Return an iterator over *all* metadata, as strings.""" + for title in self.titles: + yield "{{title: {}}}".format(title) + for author in self.authors: + yield "{{by: {}}}".format(author) + for key in sorted(self.keys): + yield "{{key: {}}}".format(str(key)) + for key in sorted(self.meta): + yield str(key) + + def __str__(self): + return ( + "\n".join(self.str_meta()).strip() + + + "\n========\n" + + + "\n".join([str(item) for item in self.content]).strip() + ) + + + def add_title(self, __ignored, title): + """Add a title""" + self._titles.insert(0, title) + + def add_subtitle(self, __ignored, title): + """Add a subtitle""" + self._subtitles.insert(0, title) + + @property + def titles(self): + """Return the list of titles (and subtitles).""" + return self._titles + self._subtitles + + def add_author(self, __ignored, title): + """Add an auhor.""" + self._authors.insert(0, title) + + @property + def authors(self): + """Return the list of (raw) authors.""" + return self._authors + + def get_directive(self, key, default=None): + """Return the first directive with a given key.""" + for directive in self.meta: + if directive.keyword == directive_name(key): + return directive.argument + return default + + def get_directives(self, key): + """Return the list of directives with a given key.""" + values = [] + for directive in self.meta: + if directive.keyword == directive_name(key): + values.append(directive.argument) + return values + + def add_key(self, __ignored, argument): + """Add a new {key: foo: bar} directive.""" + key, *argument = argument.split(":") + self._keys.append(Directive( + key.strip(), + ":".join(argument).strip(), + )) + + @property + def keys(self): + """Return the list of keys. + + That is, directive that where given of the form ``{key: foo: bar}``. + """ + return self._keys + + def meta_beginsong(self): + r"""Return the meta information to be put in \beginsong.""" + for directive in BEGINSONG_PROPERTIES: + if self.get_directive(directive) is not None: + yield (directive, self.get_directive(directive)) + for (key, value) in self.keys: + yield (key, value) + + + def _process_relative(self, directive): + """Return the directive, in which the argument is given relative to file + + This argument is expected to be a path (as a string). + """ + return Directive( + directive.keyword, + os.path.join( + os.path.dirname(self.filename), + directive.argument, + ), + ) + +class Newline(AST): + """New line""" + _template = "newline" + + def __str__(self): + return "" + +@functools.total_ordering +class Directive(AST): + """A directive""" + + def __init__(self, keyword="", argument=None): + super().__init__() + self._keyword = None + self.keyword = keyword + self.argument = argument + + @property + def _template(self): + """Name of the template to use to render this keyword. + + This only applies if ``self.inline == True`` + """ + return self.keyword + + @property + def keyword(self): + """Keyword of the directive.""" + return self._keyword + + @property + def inline(self): + """True iff this directive is to be rendered in the flow on the song. + """ + return self.keyword in INLINE_PROPERTIES + + @keyword.setter + def keyword(self, value): + """self.keyword setter + + Replace keyword by its canonical name if it is a shortcut. + """ + self._keyword = directive_name(value.strip()) + + def __str__(self): + if self.argument is not None: + return "{{{}: {}}}".format( + self.keyword, + self.argument, + ) + else: + return "{{{}}}".format(self.keyword) + + @property + def as_tuple(self): + """Return the directive as a tuple.""" + return (self.keyword, self.argument) + + def __eq__(self, other): + return self.as_tuple == other.as_tuple + + def __lt__(self, other): + return self.as_tuple < other.as_tuple + +class Tab(AST): + """Tablature""" + + inline = True + + def __init__(self): + super().__init__() + self.content = [] + + def prepend(self, data): + """Add an element at the beginning of content.""" + self.content.insert(0, data) + return self + + def __str__(self): + return '{{start_of_tab}}\n{}\n{{end_of_tab}}'.format( + _indent("\n".join(self.content)), + ) + diff --git a/patacrep/songs/chordpro/data/latex/chordpro.tex b/patacrep/songs/chordpro/data/latex/chordpro.tex new file mode 100644 index 00000000..77784a67 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/chordpro.tex @@ -0,0 +1,27 @@ +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% ((path)) + +(* if language is defined *) +\selectlanguage{((language))} +(* endif *) +\songcolumns{((metadata.columns))} + +\beginsong{((titles))}[ + by={((authors))}, + (* for (key, argument) in beginsong *) + ((key))={((argument))}, + (* endfor *) + ] + + (* if (metadata.cov is defined) or (metadata.vcov is defined) *) + \cover + (* endif *) + + (* for content in content *) + (* include content.template("tex") *) + (* endfor *) + +\endsong +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + diff --git a/patacrep/songs/chordpro/data/latex/content_chord.tex b/patacrep/songs/chordpro/data/latex/content_chord.tex new file mode 100644 index 00000000..bd2bd8b8 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_chord.tex @@ -0,0 +1 @@ +\[(( content.value ))] diff --git a/patacrep/songs/chordpro/data/latex/content_comment.tex b/patacrep/songs/chordpro/data/latex/content_comment.tex new file mode 100644 index 00000000..46fb1f24 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_comment.tex @@ -0,0 +1 @@ +\textnote{(( content.argument ))} diff --git a/patacrep/songs/chordpro/data/latex/content_error.tex b/patacrep/songs/chordpro/data/latex/content_error.tex new file mode 100644 index 00000000..a41faeb5 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_error.tex @@ -0,0 +1,3 @@ + +ERROR : Template not found for \verb+(( content.__class__))+. See the logs for details. + diff --git a/patacrep/songs/chordpro/data/latex/content_guitar_comment.tex b/patacrep/songs/chordpro/data/latex/content_guitar_comment.tex new file mode 100644 index 00000000..29c40ebd --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_guitar_comment.tex @@ -0,0 +1 @@ +\musicnote{(( content.argument ))} diff --git a/patacrep/songs/chordpro/data/latex/content_image.tex b/patacrep/songs/chordpro/data/latex/content_image.tex new file mode 100644 index 00000000..336233d5 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_image.tex @@ -0,0 +1 @@ +\image{(( content.argument ))} diff --git a/patacrep/songs/chordpro/data/latex/content_line.tex b/patacrep/songs/chordpro/data/latex/content_line.tex new file mode 100644 index 00000000..9b0c6af1 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_line.tex @@ -0,0 +1 @@ +(* for content in content.line *)(* include content.template("tex") *)(* endfor *) diff --git a/patacrep/songs/chordpro/data/latex/content_newline.tex b/patacrep/songs/chordpro/data/latex/content_newline.tex new file mode 100644 index 00000000..139597f9 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_newline.tex @@ -0,0 +1,2 @@ + + diff --git a/patacrep/songs/chordpro/data/latex/content_partition.tex b/patacrep/songs/chordpro/data/latex/content_partition.tex new file mode 100644 index 00000000..98bfed57 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_partition.tex @@ -0,0 +1 @@ +\lilypond{((content.argument))} diff --git a/patacrep/songs/chordpro/data/latex/content_space.tex b/patacrep/songs/chordpro/data/latex/content_space.tex new file mode 100644 index 00000000..8d1c8b69 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_space.tex @@ -0,0 +1 @@ + diff --git a/patacrep/songs/chordpro/data/latex/content_verse.tex b/patacrep/songs/chordpro/data/latex/content_verse.tex new file mode 100644 index 00000000..4b4cecbd --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_verse.tex @@ -0,0 +1,7 @@ +\begin{(( content.type ))} + (* for content in content.lines *) + (* include content.template("tex") *) + + (* endfor *) +\end{(( content.type ))} + diff --git a/patacrep/songs/chordpro/data/latex/content_word.tex b/patacrep/songs/chordpro/data/latex/content_word.tex new file mode 100644 index 00000000..d9dd7a30 --- /dev/null +++ b/patacrep/songs/chordpro/data/latex/content_word.tex @@ -0,0 +1 @@ +(( content.value )) diff --git a/patacrep/songs/chordpro/lexer.py b/patacrep/songs/chordpro/lexer.py new file mode 100644 index 00000000..58792fa2 --- /dev/null +++ b/patacrep/songs/chordpro/lexer.py @@ -0,0 +1,161 @@ +"""ChordPro lexer""" + +import logging +import ply.lex as lex + +LOGGER = logging.getLogger() + +#pylint: disable=invalid-name +tokens = ( + 'LBRACE', + 'RBRACE', + 'CHORD', + 'NEWLINE', + 'COLON', + 'WORD', + 'SPACE', + 'TEXT', + 'KEYWORD', + 'SOC', + 'EOC', + 'SOB', + 'EOB', + 'SOT', + 'EOT', +) + +class ChordProLexer: + """ChordPro Lexer class""" + # pylint: disable=too-many-public-methods + + tokens = tokens + + states = ( + ('chord', 'exclusive'), + ('directive', 'exclusive'), + ('directiveargument', 'exclusive'), + ('tablature', 'exclusive'), + ) + + t_SPACE = r'[ \t]+' + + t_chord_CHORD = r'[A-G7#m]+' # TODO This can be refined + + t_directive_SPACE = r'[ \t]+' + t_directive_KEYWORD = r'[a-zA-Z_]+' + t_directiveargument_TEXT = r'[^}]+' + + @staticmethod + def t_SOC(token): + r'{(soc|start_of_chorus)}' + return token + + @staticmethod + def t_EOC(token): + r'{(eoc|end_of_chorus)}' + return token + + @staticmethod + def t_SOB(token): + r'{(sob|start_of_bridge)}' + return token + + @staticmethod + def t_EOB(token): + r'{(eob|end_of_bridge)}' + return token + + def t_SOT(self, token): + r'{(sot|start_of_tab)}' + self.lexer.push_state('tablature') + return token + + def t_tablature_EOT(self, token): + r'{(eot|end_of_tab)}' + self.lexer.pop_state() + return token + + @staticmethod + def t_tablature_SPACE(token): + r'[ \t]+' + return token + + t_tablature_TEXT = r'[^\n]+' + t_tablature_NEWLINE = r'\n' + + def __init__(self): + self.__class__.lexer = lex.lex(module=self) + + # Define a rule so we can track line numbers + @staticmethod + def t_NEWLINE(token): + r'[\n\r]' + token.lexer.lineno += 1 + return token + + @staticmethod + def t_COMMENT(token): + r'\#.*' + pass + + @staticmethod + def t_WORD(token): + r'[^{}\n\][\t ]+' + return token + + def t_LBRACKET(self, __token): + r'\[' + self.lexer.push_state('chord') + + def t_chord_RBRACKET(self, __token): + r'\]' + self.lexer.pop_state() + + def t_LBRACE(self, token): + r'{' + self.lexer.push_state('directive') + return token + + def t_directive_RBRACE(self, token): + r'}' + self.lexer.pop_state() + return token + + def t_directiveargument_RBRACE(self, token): + r'}' + self.lexer.pop_state() + self.lexer.pop_state() + return token + + def t_directive_COLON(self, token): + r':' + self.lexer.push_state('directiveargument') + return token + + @staticmethod + def t_error(token): + """Manage errors""" + LOGGER.error("Illegal character '{}'".format(token.value[0])) + token.lexer.skip(1) + + @staticmethod + def t_chord_error(token): + """Manage errors""" + LOGGER.error("Illegal character '{}' in chord..".format(token.value[0])) + token.lexer.skip(1) + + @staticmethod + def t_tablature_error(token): + """Manage errors""" + LOGGER.error("Illegal character '{}' in tablature..".format(token.value[0])) + token.lexer.skip(1) + + @staticmethod + def t_directive_error(token): + """Manage errors""" + LOGGER.error("Illegal character '{}' in directive..".format(token.value[0])) + token.lexer.skip(1) + + def t_directiveargument_error(self, token): + """Manage errors""" + return self.t_directive_error(token) diff --git a/patacrep/songs/chordpro/syntax.py b/patacrep/songs/chordpro/syntax.py new file mode 100644 index 00000000..68afc738 --- /dev/null +++ b/patacrep/songs/chordpro/syntax.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +"""ChordPro parser""" + +import logging +import ply.yacc as yacc + +from patacrep.errors import SongbookError +from patacrep.songs.chordpro import ast +from patacrep.songs.chordpro.lexer import tokens, ChordProLexer + +LOGGER = logging.getLogger() + +class ParsingError(SongbookError): + """Parsing error.""" + + def __init__(self, message): + super().__init__(self) + self.message = message + + def __str__(self): + return self.message + + +class Parser: + """ChordPro parser class""" + + start = "song" + + def __init__(self, filename=None): + self.tokens = tokens + self.filename = filename + + @staticmethod + def __find_column(token): + """Return the column of ``token``.""" + last_cr = token.lexer.lexdata.rfind('\n', 0, token.lexpos) + if last_cr < 0: + last_cr = 0 + column = (token.lexpos - last_cr) + 1 + return column + + def p_error(self, token): + """Manage parsing errors.""" + if token: + LOGGER.error("Error in file {}, line {}:{}.".format( + str(self.filename), + token.lineno, + self.__find_column(token), + ) + ) + + def p_song(self, symbols): + """song : block song + | empty + """ + #if isinstance(symbols[1], str): + if len(symbols) == 2: + symbols[0] = ast.Song(self.filename) + else: + symbols[0] = symbols[2].add(symbols[1]) + + @staticmethod + def p_block(symbols): + """block : SPACE block + | directive NEWLINE + | line NEWLINE + | chorus NEWLINE + | tab NEWLINE + | bridge NEWLINE + | NEWLINE + """ + if len(symbols) == 3 and isinstance(symbols[1], str): + symbols[0] = symbols[2] + elif (symbols[1] is None) or (len(symbols) == 2): + symbols[0] = None + else: + symbols[0] = symbols[1] + + @staticmethod + def p_maybespace(symbols): + """maybespace : SPACE + | empty + """ + symbols[0] = None + + @staticmethod + def p_directive(symbols): + """directive : LBRACE KEYWORD directive_next RBRACE + | LBRACE SPACE KEYWORD directive_next RBRACE + """ + if len(symbols) == 5: + symbols[3].keyword = symbols[2] + symbols[0] = symbols[3] + else: + symbols[4].keyword = symbols[3] + symbols[0] = symbols[4] + + @staticmethod + def p_directive_next(symbols): + """directive_next : SPACE COLON TEXT + | COLON TEXT + | empty + """ + symbols[0] = ast.Directive() + if len(symbols) == 3: + symbols[0].argument = symbols[2].strip() + elif len(symbols) == 4: + symbols[0].argument = symbols[3].strip() + + @staticmethod + def p_line(symbols): + """line : word line_next + | chord line_next + """ + symbols[0] = symbols[2].prepend(symbols[1]) + + @staticmethod + def p_line_next(symbols): + """line_next : word line_next + | space line_next + | chord line_next + | empty + """ + if len(symbols) == 2: + symbols[0] = ast.Line() + else: + symbols[0] = symbols[2].prepend(symbols[1]) + + @staticmethod + def p_word(symbols): + """word : WORD""" + symbols[0] = ast.Word(symbols[1]) + + @staticmethod + def p_space(symbols): + """space : SPACE""" + symbols[0] = ast.Space() + + @staticmethod + def p_chord(symbols): + """chord : CHORD""" + symbols[0] = ast.Chord(symbols[1]) + + @staticmethod + def p_chorus(symbols): + """chorus : SOC maybespace NEWLINE chorus_content EOC maybespace + """ + symbols[0] = symbols[4] + + @staticmethod + def p_chorus_content(symbols): + """chorus_content : line NEWLINE chorus_content + | SPACE chorus_content + | empty + """ + if len(symbols) == 2: + symbols[0] = ast.Chorus() + elif len(symbols) == 3: + symbols[0] = symbols[2] + else: + symbols[0] = symbols[3].prepend(symbols[1]) + + @staticmethod + def p_bridge(symbols): + """bridge : SOB maybespace NEWLINE bridge_content EOB maybespace + """ + symbols[0] = symbols[4] + + @staticmethod + def p_bridge_content(symbols): + """bridge_content : line NEWLINE bridge_content + | SPACE bridge_content + | empty + """ + if len(symbols) == 2: + symbols[0] = ast.Bridge() + elif len(symbols) == 3: + symbols[0] = symbols[2] + else: + symbols[0] = symbols[3].prepend(symbols[1]) + + + @staticmethod + def p_tab(symbols): + """tab : SOT maybespace NEWLINE tab_content EOT maybespace + """ + symbols[0] = symbols[4] + + @staticmethod + def p_tab_content(symbols): + """tab_content : NEWLINE tab_content + | TEXT tab_content + | SPACE tab_content + | empty + """ + if len(symbols) == 2: + symbols[0] = ast.Tab() + else: + if symbols[1].strip(): + symbols[2].prepend(symbols[1]) + symbols[0] = symbols[2] + + @staticmethod + def p_empty(symbols): + """empty :""" + symbols[0] = None + +def parse_song(content, filename=None): + """Parse song and return its metadata.""" + return yacc.yacc( + module=Parser(filename), + debug=0, + write_tables=0, + ).parse( + content, + lexer=ChordProLexer().lexer, + ) diff --git a/patacrep/songs/chordpro/test/00.sgc b/patacrep/songs/chordpro/test/00.sgc new file mode 100644 index 00000000..e69de29b diff --git a/patacrep/songs/chordpro/test/00.txt b/patacrep/songs/chordpro/test/00.txt new file mode 100644 index 00000000..dbbd9c50 --- /dev/null +++ b/patacrep/songs/chordpro/test/00.txt @@ -0,0 +1 @@ +======== diff --git a/patacrep/songs/chordpro/test/01.sgc b/patacrep/songs/chordpro/test/01.sgc new file mode 100644 index 00000000..8ea8d2a2 --- /dev/null +++ b/patacrep/songs/chordpro/test/01.sgc @@ -0,0 +1 @@ +A verse line diff --git a/patacrep/songs/chordpro/test/01.txt b/patacrep/songs/chordpro/test/01.txt new file mode 100644 index 00000000..84cf4364 --- /dev/null +++ b/patacrep/songs/chordpro/test/01.txt @@ -0,0 +1,4 @@ +======== +{start_of_verse} + A verse line +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/02.sgc b/patacrep/songs/chordpro/test/02.sgc new file mode 100644 index 00000000..270bc746 --- /dev/null +++ b/patacrep/songs/chordpro/test/02.sgc @@ -0,0 +1 @@ +{title : A directive} diff --git a/patacrep/songs/chordpro/test/02.txt b/patacrep/songs/chordpro/test/02.txt new file mode 100644 index 00000000..f3fbd9e9 --- /dev/null +++ b/patacrep/songs/chordpro/test/02.txt @@ -0,0 +1,2 @@ +{title: A directive} +======== diff --git a/patacrep/songs/chordpro/test/03.sgc b/patacrep/songs/chordpro/test/03.sgc new file mode 100644 index 00000000..fd40910d --- /dev/null +++ b/patacrep/songs/chordpro/test/03.sgc @@ -0,0 +1,4 @@ + + + + diff --git a/patacrep/songs/chordpro/test/03.txt b/patacrep/songs/chordpro/test/03.txt new file mode 100644 index 00000000..dbbd9c50 --- /dev/null +++ b/patacrep/songs/chordpro/test/03.txt @@ -0,0 +1 @@ +======== diff --git a/patacrep/songs/chordpro/test/04.sgc b/patacrep/songs/chordpro/test/04.sgc new file mode 100644 index 00000000..6a266eea --- /dev/null +++ b/patacrep/songs/chordpro/test/04.sgc @@ -0,0 +1,3 @@ +{soc} +A one line chorus +{eoc} diff --git a/patacrep/songs/chordpro/test/04.txt b/patacrep/songs/chordpro/test/04.txt new file mode 100644 index 00000000..3336d565 --- /dev/null +++ b/patacrep/songs/chordpro/test/04.txt @@ -0,0 +1,4 @@ +======== +{start_of_chorus} + A one line chorus +{end_of_chorus} diff --git a/patacrep/songs/chordpro/test/05.sgc b/patacrep/songs/chordpro/test/05.sgc new file mode 100644 index 00000000..9472ebcb --- /dev/null +++ b/patacrep/songs/chordpro/test/05.sgc @@ -0,0 +1,3 @@ +{sob} +A one line bridge +{eob} diff --git a/patacrep/songs/chordpro/test/05.txt b/patacrep/songs/chordpro/test/05.txt new file mode 100644 index 00000000..b2f99be2 --- /dev/null +++ b/patacrep/songs/chordpro/test/05.txt @@ -0,0 +1,4 @@ +======== +{start_of_bridge} + A one line bridge +{end_of_bridge} diff --git a/patacrep/songs/chordpro/test/06.sgc b/patacrep/songs/chordpro/test/06.sgc new file mode 100644 index 00000000..90a2b559 --- /dev/null +++ b/patacrep/songs/chordpro/test/06.sgc @@ -0,0 +1 @@ +# A comment diff --git a/patacrep/songs/chordpro/test/06.txt b/patacrep/songs/chordpro/test/06.txt new file mode 100644 index 00000000..dbbd9c50 --- /dev/null +++ b/patacrep/songs/chordpro/test/06.txt @@ -0,0 +1 @@ +======== diff --git a/patacrep/songs/chordpro/test/07.sgc b/patacrep/songs/chordpro/test/07.sgc new file mode 100644 index 00000000..fd183eff --- /dev/null +++ b/patacrep/songs/chordpro/test/07.sgc @@ -0,0 +1,3 @@ +{sot} +A tab +{eot} diff --git a/patacrep/songs/chordpro/test/07.txt b/patacrep/songs/chordpro/test/07.txt new file mode 100644 index 00000000..7fb8bc64 --- /dev/null +++ b/patacrep/songs/chordpro/test/07.txt @@ -0,0 +1,4 @@ +======== +{start_of_tab} + A tab +{end_of_tab} diff --git a/patacrep/songs/chordpro/test/08.sgc b/patacrep/songs/chordpro/test/08.sgc new file mode 100644 index 00000000..b51646ca --- /dev/null +++ b/patacrep/songs/chordpro/test/08.sgc @@ -0,0 +1,10 @@ + + + +# comment +# comment + + +A lot of new lines + +# comment diff --git a/patacrep/songs/chordpro/test/08.txt b/patacrep/songs/chordpro/test/08.txt new file mode 100644 index 00000000..92c183b1 --- /dev/null +++ b/patacrep/songs/chordpro/test/08.txt @@ -0,0 +1,4 @@ +======== +{start_of_verse} + A lot of new lines +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/09.sgc b/patacrep/songs/chordpro/test/09.sgc new file mode 100644 index 00000000..b831f230 --- /dev/null +++ b/patacrep/songs/chordpro/test/09.sgc @@ -0,0 +1,15 @@ + + + +# comment +# comment + + +A lot of new lines + +# comment + +{title: and a directive} + +# comment + diff --git a/patacrep/songs/chordpro/test/09.txt b/patacrep/songs/chordpro/test/09.txt new file mode 100644 index 00000000..b7669e62 --- /dev/null +++ b/patacrep/songs/chordpro/test/09.txt @@ -0,0 +1,5 @@ +{title: and a directive} +======== +{start_of_verse} + A lot of new lines +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/10.sgc b/patacrep/songs/chordpro/test/10.sgc new file mode 100644 index 00000000..6519ab80 --- /dev/null +++ b/patacrep/songs/chordpro/test/10.sgc @@ -0,0 +1 @@ +A line[A] with a chord diff --git a/patacrep/songs/chordpro/test/10.txt b/patacrep/songs/chordpro/test/10.txt new file mode 100644 index 00000000..b96d8637 --- /dev/null +++ b/patacrep/songs/chordpro/test/10.txt @@ -0,0 +1,4 @@ +======== +{start_of_verse} + A line[A] with a chord +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/11.sgc b/patacrep/songs/chordpro/test/11.sgc new file mode 100644 index 00000000..fc3d697d --- /dev/null +++ b/patacrep/songs/chordpro/test/11.sgc @@ -0,0 +1 @@ +A line ending with a chord[A] diff --git a/patacrep/songs/chordpro/test/11.txt b/patacrep/songs/chordpro/test/11.txt new file mode 100644 index 00000000..2a9eaf17 --- /dev/null +++ b/patacrep/songs/chordpro/test/11.txt @@ -0,0 +1,4 @@ +======== +{start_of_verse} + A line ending with a chord[A] +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/12.sgc b/patacrep/songs/chordpro/test/12.sgc new file mode 100644 index 00000000..a9583451 --- /dev/null +++ b/patacrep/songs/chordpro/test/12.sgc @@ -0,0 +1 @@ +[A]A line starting with a chord diff --git a/patacrep/songs/chordpro/test/12.txt b/patacrep/songs/chordpro/test/12.txt new file mode 100644 index 00000000..83c11625 --- /dev/null +++ b/patacrep/songs/chordpro/test/12.txt @@ -0,0 +1,4 @@ +======== +{start_of_verse} + [A]A line starting with a chord +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/13.sgc b/patacrep/songs/chordpro/test/13.sgc new file mode 100644 index 00000000..6cc3dfbc --- /dev/null +++ b/patacrep/songs/chordpro/test/13.sgc @@ -0,0 +1,5 @@ +{sot} + A table + wit many # weir [ + [ symbols +{eot} diff --git a/patacrep/songs/chordpro/test/13.txt b/patacrep/songs/chordpro/test/13.txt new file mode 100644 index 00000000..447f67dd --- /dev/null +++ b/patacrep/songs/chordpro/test/13.txt @@ -0,0 +1,6 @@ +======== +{start_of_tab} + A table + wit many # weir [ + [ symbols +{end_of_tab} diff --git a/patacrep/songs/chordpro/test/21.sgc b/patacrep/songs/chordpro/test/21.sgc new file mode 100644 index 00000000..c01b31ad --- /dev/null +++ b/patacrep/songs/chordpro/test/21.sgc @@ -0,0 +1 @@ + A verse line diff --git a/patacrep/songs/chordpro/test/21.txt b/patacrep/songs/chordpro/test/21.txt new file mode 100644 index 00000000..84cf4364 --- /dev/null +++ b/patacrep/songs/chordpro/test/21.txt @@ -0,0 +1,4 @@ +======== +{start_of_verse} + A verse line +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/22.sgc b/patacrep/songs/chordpro/test/22.sgc new file mode 100644 index 00000000..425fd584 --- /dev/null +++ b/patacrep/songs/chordpro/test/22.sgc @@ -0,0 +1 @@ + {title : A directive} diff --git a/patacrep/songs/chordpro/test/22.txt b/patacrep/songs/chordpro/test/22.txt new file mode 100644 index 00000000..f3fbd9e9 --- /dev/null +++ b/patacrep/songs/chordpro/test/22.txt @@ -0,0 +1,2 @@ +{title: A directive} +======== diff --git a/patacrep/songs/chordpro/test/23.sgc b/patacrep/songs/chordpro/test/23.sgc new file mode 100644 index 00000000..ce5ed9dd --- /dev/null +++ b/patacrep/songs/chordpro/test/23.sgc @@ -0,0 +1,4 @@ + + + + diff --git a/patacrep/songs/chordpro/test/23.txt b/patacrep/songs/chordpro/test/23.txt new file mode 100644 index 00000000..dbbd9c50 --- /dev/null +++ b/patacrep/songs/chordpro/test/23.txt @@ -0,0 +1 @@ +======== diff --git a/patacrep/songs/chordpro/test/24.sgc b/patacrep/songs/chordpro/test/24.sgc new file mode 100644 index 00000000..a929a754 --- /dev/null +++ b/patacrep/songs/chordpro/test/24.sgc @@ -0,0 +1,3 @@ + {soc} + A one line chorus + {eoc} diff --git a/patacrep/songs/chordpro/test/24.txt b/patacrep/songs/chordpro/test/24.txt new file mode 100644 index 00000000..3336d565 --- /dev/null +++ b/patacrep/songs/chordpro/test/24.txt @@ -0,0 +1,4 @@ +======== +{start_of_chorus} + A one line chorus +{end_of_chorus} diff --git a/patacrep/songs/chordpro/test/25.sgc b/patacrep/songs/chordpro/test/25.sgc new file mode 100644 index 00000000..45efba59 --- /dev/null +++ b/patacrep/songs/chordpro/test/25.sgc @@ -0,0 +1,3 @@ + {sob} + A one line bridge + {eob} diff --git a/patacrep/songs/chordpro/test/25.txt b/patacrep/songs/chordpro/test/25.txt new file mode 100644 index 00000000..b2f99be2 --- /dev/null +++ b/patacrep/songs/chordpro/test/25.txt @@ -0,0 +1,4 @@ +======== +{start_of_bridge} + A one line bridge +{end_of_bridge} diff --git a/patacrep/songs/chordpro/test/26.sgc b/patacrep/songs/chordpro/test/26.sgc new file mode 100644 index 00000000..372b96b4 --- /dev/null +++ b/patacrep/songs/chordpro/test/26.sgc @@ -0,0 +1 @@ + # A comment diff --git a/patacrep/songs/chordpro/test/26.txt b/patacrep/songs/chordpro/test/26.txt new file mode 100644 index 00000000..dbbd9c50 --- /dev/null +++ b/patacrep/songs/chordpro/test/26.txt @@ -0,0 +1 @@ +======== diff --git a/patacrep/songs/chordpro/test/27.sgc b/patacrep/songs/chordpro/test/27.sgc new file mode 100644 index 00000000..bfa0cf0e --- /dev/null +++ b/patacrep/songs/chordpro/test/27.sgc @@ -0,0 +1,3 @@ + {sot} + A tab + {eot} diff --git a/patacrep/songs/chordpro/test/27.txt b/patacrep/songs/chordpro/test/27.txt new file mode 100644 index 00000000..7fb8bc64 --- /dev/null +++ b/patacrep/songs/chordpro/test/27.txt @@ -0,0 +1,4 @@ +======== +{start_of_tab} + A tab +{end_of_tab} diff --git a/patacrep/songs/chordpro/test/28.sgc b/patacrep/songs/chordpro/test/28.sgc new file mode 100644 index 00000000..69f980b3 --- /dev/null +++ b/patacrep/songs/chordpro/test/28.sgc @@ -0,0 +1,10 @@ + + + + # comment +# comment + + + A lot of new lines + +# comment diff --git a/patacrep/songs/chordpro/test/28.txt b/patacrep/songs/chordpro/test/28.txt new file mode 100644 index 00000000..92c183b1 --- /dev/null +++ b/patacrep/songs/chordpro/test/28.txt @@ -0,0 +1,4 @@ +======== +{start_of_verse} + A lot of new lines +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/29.sgc b/patacrep/songs/chordpro/test/29.sgc new file mode 100644 index 00000000..830e0177 --- /dev/null +++ b/patacrep/songs/chordpro/test/29.sgc @@ -0,0 +1,15 @@ + + + +# comment +# comment + + + A lot of new lines + +# comment + + {title: and a directive} + +# comment + diff --git a/patacrep/songs/chordpro/test/29.txt b/patacrep/songs/chordpro/test/29.txt new file mode 100644 index 00000000..b7669e62 --- /dev/null +++ b/patacrep/songs/chordpro/test/29.txt @@ -0,0 +1,5 @@ +{title: and a directive} +======== +{start_of_verse} + A lot of new lines +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/30.sgc b/patacrep/songs/chordpro/test/30.sgc new file mode 100644 index 00000000..ee67061c --- /dev/null +++ b/patacrep/songs/chordpro/test/30.sgc @@ -0,0 +1 @@ + [A]A line starting with a chord diff --git a/patacrep/songs/chordpro/test/__init__.py b/patacrep/songs/chordpro/test/__init__.py new file mode 100644 index 00000000..b73d2af8 --- /dev/null +++ b/patacrep/songs/chordpro/test/__init__.py @@ -0,0 +1 @@ +"""Test for chordpro parser""" diff --git a/patacrep/songs/chordpro/test/greensleeves.sgc b/patacrep/songs/chordpro/test/greensleeves.sgc new file mode 100644 index 00000000..1dee93ff --- /dev/null +++ b/patacrep/songs/chordpro/test/greensleeves.sgc @@ -0,0 +1,44 @@ +{language : english} +{columns : 2} +{subtitle : Un sous titre} +{ title : Greensleeves} +{title : Un autre sous-titre} +{artist: Traditionnel} +{cover : traditionnel } +{album :Angleterre} + +{partition : greensleeves.ly} + + +A[Am]las, my love, ye [G]do me wrong +To [Am]cast me oft dis[E]curteously +And [Am]I have loved [G]you so long +De[Am]lighting [E]in your [Am]companie + +{start_of_chorus} + [C]Green[B]sleeves was [G]all my joy + [Am]Greensleeves was [E]my delight + [C]Greensleeves was my [G]heart of gold + And [Am]who but [E]Ladie [Am]Greensleeves +{end_of_chorus} + +I [Am]have been ready [G]at your hand +To [Am]grant what ever [E]you would crave +I [Am]have both waged [G]life and land +Your [Am]love and [E]good will [Am]for to have + +I [Am]bought thee kerchers [G]to thy head +That [Am]were wrought fine and [E]gallantly +I [Am]kept thee both at [G]boord and bed +Which [Am]cost my [E]purse well [Am]favouredly + +I [Am]bought thee peticotes [G]of the best +The [Am]cloth so fine as [E]fine might be +I [Am]gave thee jewels [G]for thy chest +And [Am]all this [E]cost I [Am]spent on thee + + +Thy [Am]smock of silke, both [G]faire and white +With [Am]gold embrodered [E]gorgeously +Thy [Am]peticote of [G]sendall right +And [Am]this I [E]bought thee [Am]gladly diff --git a/patacrep/songs/chordpro/test/greensleeves.txt b/patacrep/songs/chordpro/test/greensleeves.txt new file mode 100644 index 00000000..069fbba8 --- /dev/null +++ b/patacrep/songs/chordpro/test/greensleeves.txt @@ -0,0 +1,52 @@ +{title: Greensleeves} +{title: Un autre sous-titre} +{title: Un sous titre} +{by: Traditionnel} +{album: Angleterre} +{columns: 2} +{cov: DIRNAME/traditionnel} +{language: english} +======== +{partition: DIRNAME/greensleeves.ly} + +{start_of_verse} + A[Am]las, my love, ye [G]do me wrong + To [Am]cast me oft dis[E]curteously + And [Am]I have loved [G]you so long + De[Am]lighting [E]in your [Am]companie +{end_of_verse} + +{start_of_chorus} + [C]Green[B]sleeves was [G]all my joy + [Am]Greensleeves was [E]my delight + [C]Greensleeves was my [G]heart of gold + And [Am]who but [E]Ladie [Am]Greensleeves +{end_of_chorus} + +{start_of_verse} + I [Am]have been ready [G]at your hand + To [Am]grant what ever [E]you would crave + I [Am]have both waged [G]life and land + Your [Am]love and [E]good will [Am]for to have +{end_of_verse} + +{start_of_verse} + I [Am]bought thee kerchers [G]to thy head + That [Am]were wrought fine and [E]gallantly + I [Am]kept thee both at [G]boord and bed + Which [Am]cost my [E]purse well [Am]favouredly +{end_of_verse} + +{start_of_verse} + I [Am]bought thee peticotes [G]of the best + The [Am]cloth so fine as [E]fine might be + I [Am]gave thee jewels [G]for thy chest + And [Am]all this [E]cost I [Am]spent on thee +{end_of_verse} + +{start_of_verse} + Thy [Am]smock of silke, both [G]faire and white + With [Am]gold embrodered [E]gorgeously + Thy [Am]peticote of [G]sendall right + And [Am]this I [E]bought thee [Am]gladly +{end_of_verse} diff --git a/patacrep/songs/chordpro/test/metadata.sgc b/patacrep/songs/chordpro/test/metadata.sgc new file mode 100644 index 00000000..eee1bf9d --- /dev/null +++ b/patacrep/songs/chordpro/test/metadata.sgc @@ -0,0 +1,20 @@ +{subtitle: Subtitle3} +{title: Title} +{title: Subtitle1} +{subtitle: Subtitle4} +{t: Subtitle2} +{st: Subtitle5} +{language: french} +{language: english} +{by: Author1} +{artist: Author2} +{album: Albom} +{copyright: Copyright} +{cover: Cover} +{vcover: VCover} +{capo: Capo} +{key: foo: Foo} +{comment: Comment} +{guitar_comment: GuitarComment} +{image: Image} +{partition: Lilypond} diff --git a/patacrep/songs/chordpro/test/metadata.txt b/patacrep/songs/chordpro/test/metadata.txt new file mode 100644 index 00000000..1ba5f5c6 --- /dev/null +++ b/patacrep/songs/chordpro/test/metadata.txt @@ -0,0 +1,21 @@ +{title: Title} +{title: Subtitle1} +{title: Subtitle2} +{title: Subtitle3} +{title: Subtitle4} +{title: Subtitle5} +{by: Author1} +{by: Author2} +{key: {foo: Foo}} +{album: Albom} +{capo: Capo} +{copyright: Copyright} +{cov: DIRNAME/Cover} +{language: english} +{language: french} +{vcov: VCover} +======== +{comment: Comment} +{guitar_comment: GuitarComment} +{image: DIRNAME/Image} +{partition: DIRNAME/Lilypond} diff --git a/patacrep/songs/chordpro/test/test_parser.py b/patacrep/songs/chordpro/test/test_parser.py new file mode 100644 index 00000000..2743522e --- /dev/null +++ b/patacrep/songs/chordpro/test/test_parser.py @@ -0,0 +1,56 @@ +"""Tests for the chordpro parser.""" + +# pylint: disable=too-few-public-methods + +import glob +import os +import unittest + +from patacrep.songs.chordpro import syntax as chordpro + +class ParserTxtRenderer(unittest.TestCase): + """Test parser, and renderer as a txt file.""" + + maxDiff = None + + def __init__(self, methodname="runTest", basename=None): + super().__init__(methodname) + self.basename = basename + + def shortDescription(self): + return "Parsing file '{}.txt'.".format(self.basename) + + def runTest(self): + """Test txt output (default, debug output).""" + # pylint: disable=invalid-name + + if self.basename is None: + return + with open("{}.sgc".format(self.basename), 'r', encoding='utf8') as sourcefile: + with open("{}.txt".format(self.basename), 'r', encoding='utf8') as expectfile: + #print(os.path.basename(sourcefile.name)) + #with open("{}.txt.diff".format(self.basename), 'w', encoding='utf8') as difffile: + # difffile.write( + # str(chordpro.parse_song( + # sourcefile.read(), + # os.path.basename(sourcefile.name), + # )).strip() + # ) + # sourcefile.seek(0) + self.assertMultiLineEqual( + str(chordpro.parse_song( + sourcefile.read(), + os.path.abspath(sourcefile.name), + )).strip(), + expectfile.read().strip().replace("DIRNAME", os.path.dirname(self.basename)), + ) + +def load_tests(__loader, tests, __pattern): + """Load several tests given test files present in the directory.""" + # Load all txt files as tests + for txt in sorted(glob.glob(os.path.join( + os.path.dirname(__file__), + '*.txt', + ))): + tests.addTest(ParserTxtRenderer(basename=txt[:-len('.txt')])) + return tests diff --git a/patacrep/songs/latex/__init__.py b/patacrep/songs/latex/__init__.py new file mode 100644 index 00000000..ce08d368 --- /dev/null +++ b/patacrep/songs/latex/__init__.py @@ -0,0 +1,39 @@ +"""Very simple LaTeX parser + +This module uses an LALR parser to try to parse LaTeX code. LaTeX language +*cannot* be parsed by an LALR parser, so this is a very simple attemps, which +will work on simple cases, but not on complex ones. +""" + +import os + +from patacrep import files, encoding +from patacrep.latex import parse_song +from patacrep.songs import Song + +class LatexSong(Song): + """LaTeX song parser.""" + + def parse(self, __config): + """Parse content, and return the dictinory of song data.""" + with encoding.open_read(self.fullpath, encoding=self.encoding) as song: + self.data = parse_song(song.read(), self.fullpath) + self.titles = self.data['@titles'] + del self.data['@titles'] + self.languages = self.data['@languages'] + del self.data['@languages'] + self.authors = [self.data['by']] + del self.data['by'] + + def tex(self, output): + """Return the LaTeX code rendering the song.""" + return r'\input{{{}}}'.format(files.path2posix( + files.relpath( + self.fullpath, + os.path.dirname(output) + ))) + +SONG_PARSERS = { + 'is': LatexSong, + 'sg': LatexSong, + } diff --git a/patacrep/songs/tex.py b/patacrep/songs/tex.py deleted file mode 100644 index a8408aaf..00000000 --- a/patacrep/songs/tex.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Very simple LaTeX parsing.""" - -import os - -from patacrep import files -from patacrep.latex import parsesong -from patacrep.songs import Song - -class TexRenderer(Song): - """Renderer for song and intersong files.""" - - def parse(self): - """Parse song and set metadata.""" - self.data = parsesong(self.fullpath, self.encoding) - self.titles = self.data['@titles'] - self.languages = self.data['@languages'] - self.authors = self.data['by'] - - def render(self, context): - """Return the string that will render the song.""" - return r'\input{{{}}}'.format(files.path2posix( - files.relpath( - self.fullpath, - os.path.dirname(context['filename']) - ))) - -FILE_PLUGINS = { - 'sg': TexRenderer, - 'is': TexRenderer, - } diff --git a/patacrep/templates.py b/patacrep/templates.py index ed48529f..6849ad36 100644 --- a/patacrep/templates.py +++ b/patacrep/templates.py @@ -64,9 +64,29 @@ def _escape_tex(value): return newval -class TexRenderer(object): +class TexRenderer: """Render a template to a LaTeX file.""" + def __init__(self, template, texenv, encoding=None): + self.encoding = encoding + self.texenv = texenv + self.texenv.block_start_string = '(*' + self.texenv.block_end_string = '*)' + self.texenv.variable_start_string = '((' + self.texenv.variable_end_string = '))' + self.texenv.comment_start_string = '(% comment %)' + self.texenv.comment_end_string = '(% endcomment %)' + self.texenv.line_comment_prefix = '%!' + self.texenv.filters['escape_tex'] = _escape_tex + self.texenv.trim_blocks = True + self.texenv.lstrip_blocks = True + self.texenv.globals["path2posix"] = files.path2posix + self.template = self.texenv.get_template(template) + + +class TexBookRenderer(TexRenderer): + """Tex renderer for the whole songbook""" + def __init__(self, template, datadirs, lang, encoding=None): '''Start a new jinja2 environment for .tex creation. @@ -78,29 +98,15 @@ class TexRenderer(object): - encoding: if set, encoding of the template. ''' self.lang = lang - self.encoding = encoding # Load templates in filesystem ... loaders = [FileSystemLoader(os.path.join(datadir, 'templates')) for datadir in datadirs] - self.texenv = Environment( + texenv = Environment( loader=ChoiceLoader(loaders), extensions=[VariablesExtension], ) - self.texenv.block_start_string = '(*' - self.texenv.block_end_string = '*)' - self.texenv.variable_start_string = '((' - self.texenv.variable_end_string = '))' - self.texenv.comment_start_string = '(% comment %)' - self.texenv.comment_end_string = '(% endcomment %)' - self.texenv.line_comment_prefix = '%!' - self.texenv.filters['escape_tex'] = _escape_tex - self.texenv.trim_blocks = True - self.texenv.lstrip_blocks = True - - self.texenv.globals["path2posix"] = files.path2posix - try: - self.template = self.texenv.get_template(template) + super().__init__(template, texenv, encoding) except TemplateNotFound as exception: # Only works if all loaders are FileSystemLoader(). paths = [ diff --git a/patacrep/test.py b/patacrep/test.py new file mode 100644 index 00000000..ce135fef --- /dev/null +++ b/patacrep/test.py @@ -0,0 +1,26 @@ +"""Tests""" + +import doctest +import os +import unittest + +import patacrep + +def suite(): + """Return a TestSuite object, to test whole `patacrep` package. + + Both unittest and doctest are tested. + """ + test_loader = unittest.defaultTestLoader + return test_loader.discover(os.path.dirname(__file__)) + +def load_tests(__loader, tests, __pattern): + """Load tests (unittests and doctests).""" + # Loading doctests + tests.addTests(doctest.DocTestSuite(patacrep)) + + # Unittests are loaded by default + return tests + +if __name__ == "__main__": + unittest.TextTestRunner().run(suite()) diff --git a/setup.py b/setup.py index d153d49e..5a315fbd 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ setup( "Programming Language :: Python :: 3.4", "Topic :: Utilities", ], - platforms=["GNU/Linux", "Windows", "MacOsX"] + platforms=["GNU/Linux", "Windows", "MacOsX"], + test_suite="patacrep.test.suite", long_description = open("README.rst", "r").read(), )