diff --git a/.travis.yml b/.travis.yml index c398bbfe..0f4e5c8d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,11 @@ git: depth: 1 language: python python: - - 3.4 + - 3.5 install: - pip install tox script: - - tox + - tox -e lint,py35 sudo: false # addons: # apt: diff --git a/patacrep/build.py b/patacrep/build.py index aa18e1b8..83e7efdb 100644 --- a/patacrep/build.py +++ b/patacrep/build.py @@ -13,7 +13,6 @@ import yaml from patacrep import authors, content, encoding, errors, files, pkg_datapath, utils from patacrep.index import process_sxd from patacrep.templates import TexBookRenderer, iter_bookoptions -from patacrep.songs import DataSubpath LOGGER = logging.getLogger(__name__) EOL = "\n" @@ -55,25 +54,6 @@ class 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""" - abs_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: - LOGGER.warning( - "Ignoring non-existent datadir '{}'.".format(path) - ) - - self._raw_config['_datadir'] = abs_datadir - self._raw_config['_songdir'] = [ - DataSubpath(path, 'songs') - for path in self._raw_config['_datadir'] - ] def write_tex(self, output): """Build the '.tex' file corresponding to self. @@ -124,7 +104,12 @@ class Songbook: self._config['_bookoptions'] = iter_bookoptions(self._config) renderer.render_tex(output, self._config) + + # Get all errors, and maybe exit program self._errors.extend(renderer.errors) + if self._config['_error'] == "failonbook": + if self.has_errors(): + raise errors.SongbookError("Some songs contain errors. Stopping as requested.") def has_errors(self): """Return `True` iff errors have been encountered in the book. @@ -182,11 +167,11 @@ class SongbookBuilder: # are function; values are return values of functions. _called_functions = {} - def __init__(self, raw_songbook, basename): - # Representation of the .yaml songbook configuration file. - self.songbook = Songbook(raw_songbook, basename) + def __init__(self, raw_songbook): # Basename of the songbook to be built. - self.basename = basename + self.basename = raw_songbook['_basename'] + # Representation of the .sb songbook configuration file. + self.songbook = Songbook(raw_songbook, self.basename) def _run_once(self, function, *args, **kwargs): """Run function if it has not been run yet. diff --git a/patacrep/content/song.py b/patacrep/content/song.py index 56ed0205..192294a0 100755 --- a/patacrep/content/song.py +++ b/patacrep/content/song.py @@ -24,6 +24,12 @@ class SongRenderer(ContentItem): """Iterate over song errors.""" yield from self.song.errors + def has_errors(self): + """Return `True` iff errors has been found.""" + for _ in self.iter_errors(): + return True + return False + def begin_new_block(self, previous, __context): """Return a boolean stating if a new block is to be created.""" return not isinstance(previous, SongRenderer) @@ -58,6 +64,7 @@ class SongRenderer(ContentItem): return self.song.fullpath < other.song.fullpath #pylint: disable=unused-argument +#pylint: disable=too-many-branches @validate_parser_argument(""" type: //any of: @@ -117,7 +124,17 @@ def parse(keyword, argument, config): )) except ContentError as error: songlist.append_error(error) + if config['_error'] == "failonsong": + raise errors.SongbookError( + "Error in song '{}'. Stopping as requested." + .format(os.path.join(songdir.fullpath, filename)) + ) continue + if renderer.has_errors() and config['_error'] == "failonsong": + raise errors.SongbookError( + "Error in song '{}'. Stopping as requested." + .format(os.path.join(songdir.fullpath, filename)) + ) songlist.append(renderer) config["_langs"].add(renderer.song.lang) if len(songlist) > before: diff --git a/patacrep/data/latex/patacrep.sty b/patacrep/data/latex/patacrep.sty index dc817ceb..3dc6c7bf 100644 --- a/patacrep/data/latex/patacrep.sty +++ b/patacrep/data/latex/patacrep.sty @@ -78,6 +78,9 @@ %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% % Unicode characters \RequirePackage{fontspec} +\RequirePackage{newunicodechar} + +\newunicodechar{₂}{\ensuremath{_2}} %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff --git a/patacrep/data/templates/songbook_model.yml b/patacrep/data/templates/songbook_model.yml index fbdd90f6..19fb66fb 100644 --- a/patacrep/data/templates/songbook_model.yml +++ b/patacrep/data/templates/songbook_model.yml @@ -6,9 +6,14 @@ schema: required: _cache: //bool _filepath: //str + _basename: //str + _error: //str _datadir: type: //arr contents: //str + _songdir: + type: //arr + contents: //any book: type: //rec required: @@ -80,6 +85,7 @@ schema: - type: //nil default: en: + _datadir: [] # For test reasons book: lang: en encoding: utf-8 diff --git a/patacrep/errors.py b/patacrep/errors.py index 5bb3896d..e32cd0e1 100644 --- a/patacrep/errors.py +++ b/patacrep/errors.py @@ -5,14 +5,15 @@ class SongbookError(Exception): Songbook errors should inherit from this one. """ - pass + def __init__(self, message=None): + super().__init__() + self.message = message class SchemaError(SongbookError): """Error on the songbook schema""" def __init__(self, message='', rx_exception=None): - super().__init__() - self.message = message + super().__init__(message) self.rx_exception = rx_exception def __str__(self): diff --git a/patacrep/songbook/__init__.py b/patacrep/songbook/__init__.py index e69de29b..34c77eb5 100644 --- a/patacrep/songbook/__init__.py +++ b/patacrep/songbook/__init__.py @@ -0,0 +1,103 @@ +"""Raw songbook utilities""" + +import logging +import os +import sys +import yaml + +from patacrep import encoding +from patacrep.build import config_model +from patacrep.utils import DictOfDict +from patacrep.songs import DataSubpath +import patacrep + +LOGGER = logging.getLogger() + +def open_songbook(filename): + """Open songbook, and return a raw songbook object. + + :param str filename: Filename of the yaml songbook. + :rvalue: dict + :return: Songbook, as a dictionary. + """ + if os.path.exists(filename + ".yaml") and not os.path.exists(filename): + filename += ".yaml" + + try: + with patacrep.encoding.open_read(filename) as songbook_file: + user_songbook = yaml.load(songbook_file) + if 'encoding' in user_songbook.get('book', []): + with encoding.open_read( + filename, + encoding=user_songbook['book']['encoding'] + ) as songbook_file: + user_songbook = yaml.load(songbook_file) + except Exception as error: # pylint: disable=broad-except + raise patacrep.errors.SongbookError(str(error)) + + songbook = _add_songbook_defaults(user_songbook) + + songbook['_filepath'] = filename + songbook['_basename'] = os.path.basename(filename)[:-len(".yaml")] + + # Gathering datadirs + songbook['_datadir'] = list(_iter_absolute_datadirs(songbook)) + if 'datadir' in songbook['book']: + del songbook['book']['datadir'] + + songbook['_songdir'] = [ + DataSubpath(path, 'songs') + for path in songbook['_datadir'] + ] + + + return songbook + +def _add_songbook_defaults(user_songbook): + """ Adds the defaults values to the songbook if missing from + the user songbook + + Priority: + - User values + - Default values of the user lang (if set) + - Default english values + """ + + # Merge the default and user configs + locale_default = config_model('default') + # Initialize with default in english + default_songbook = locale_default.get('en', {}) + default_songbook = DictOfDict(default_songbook) + + if 'lang' in user_songbook.get('book', []): + # Update default with current lang + lang = user_songbook['book']['lang'] + default_songbook.update(locale_default.get(lang, {})) + # Update default with user_songbook + default_songbook.update(user_songbook) + + return dict(default_songbook) + +def _iter_absolute_datadirs(raw_songbook): + """Iterate on the absolute datadirs of the raw songbook + + Appends the songfile dir at the end + """ + datadir = raw_songbook.get('book', {}).get('datadir') + filepath = raw_songbook['_filepath'] + + if datadir is None: + datadir = [] + elif isinstance(datadir, str): + datadir = [datadir] + basedir = os.path.dirname(os.path.abspath(filepath)) + + for path in datadir: + abspath = os.path.join(basedir, path) + if os.path.exists(abspath) and os.path.isdir(abspath): + yield abspath + else: + LOGGER.warning( + "Ignoring non-existent datadir '{}'.".format(path) + ) + yield basedir diff --git a/patacrep/songbook/__main__.py b/patacrep/songbook/__main__.py index f02e27db..6793b6f1 100644 --- a/patacrep/songbook/__main__.py +++ b/patacrep/songbook/__main__.py @@ -3,16 +3,14 @@ import argparse import locale import logging -import os.path -import textwrap import sys -import yaml +import textwrap -from patacrep.build import SongbookBuilder, DEFAULT_STEPS, config_model -from patacrep.utils import yesno, DictOfDict +from patacrep.build import SongbookBuilder, DEFAULT_STEPS +from patacrep.utils import yesno from patacrep import __version__ from patacrep import errors -import patacrep.encoding +from patacrep.songbook import open_songbook # Logging configuration logging.basicConfig(level=logging.INFO) @@ -53,6 +51,7 @@ def argument_parser(args): parser = argparse.ArgumentParser( prog="songbook", description="A song book compiler", + formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument( @@ -67,9 +66,8 @@ def argument_parser(args): parser.add_argument( '--datadir', '-d', nargs='+', type=str, action='append', help=textwrap.dedent("""\ - Data location. Expected (not necessarily required) - subdirectories are 'songs', 'img', 'latex', 'templates'. - """) + Data location. Expected (not necessarily required) subdirectories are 'songs', 'img', 'latex', 'templates'. + """), ) parser.add_argument( @@ -88,24 +86,38 @@ def argument_parser(args): default=[True], ) + parser.add_argument( + '--error', '-e', nargs=1, + help=textwrap.dedent("""\ + By default, this program tries hard to fix or ignore song and book errors. This option changes this behaviour: + - failonsong: stop as soon as a song contains (at least) one error; + - failonbook: stop when all the songs have been parsed and rendered, if any error was met; + - fix: tries to fix (or ignore) errors. + + Note that compilation *may* fail even with `--error=fix`. + """), + type=str, + choices=[ + "failonsong", + "failonbook", + "fix", + ], + default=["fix"], + ) + parser.add_argument( '--steps', '-s', nargs=1, type=str, action=ParseStepsAction, help=textwrap.dedent("""\ - Steps to run. Default is "{steps}". - Available steps are: - "tex" produce .tex file from templates; - "pdf" compile .tex file; - "sbx" compile index files; - "clean" remove temporary files; - any string beginning with '#' (in this case, it will be run - in a shell). Several steps (excepted the custom shell - command) can be combinend in one --steps argument, as a - comma separated string. - - Substring {{basename}} is replaced by the basename of the song - book, and substrings {{aux}}, {{log}}, {{out}}, {{pdf}}, {{sxc}}, {{tex}} - are replaced by ".aux", ".log", and so on. + Steps to run. Default is "{steps}". Available steps are: + - "tex" produce .tex file from templates; + - "pdf" compile .tex file; + - "sbx" compile index files; + - "clean" remove temporary files; + - any string beginning with '#' (in this case, it will be run in a shell). + Several steps (excepted the custom shell command) can be combinend in one --steps argument, as a comma separated string. + + Substring {{basename}} is replaced by the basename of the song book, and substrings {{aux}}, {{log}}, {{out}}, {{pdf}}, {{sxc}}, {{tex}} are replaced by ".aux", ".log", and so on. """.format(steps=','.join(DEFAULT_STEPS))), default=None, ) @@ -115,8 +127,10 @@ def argument_parser(args): return options -def main(): +def main(args=None): """Main function:""" + if args is None: + args = sys.argv # set script locale to match user's try: @@ -125,55 +139,22 @@ def main(): # Locale is not installed on user's system, or wrongly configured. LOGGER.error("Locale error: {}\n".format(str(error))) - options = argument_parser(sys.argv[1:]) + options = argument_parser(args[1:]) songbook_path = options.book[-1] - if os.path.exists(songbook_path + ".yaml") and not os.path.exists(songbook_path): - songbook_path += ".yaml" - - basename = os.path.basename(songbook_path)[:-len(".yaml")] # Load the user songbook config try: - with patacrep.encoding.open_read(songbook_path) as songbook_file: - user_songbook = yaml.load(songbook_file) - if 'encoding' in user_songbook.get('book', []): - with patacrep.encoding.open_read( - songbook_path, - encoding=user_songbook['book']['encoding'] - ) as songbook_file: - user_songbook = yaml.load(songbook_file) - except Exception as error: # pylint: disable=broad-except - LOGGER.error(error) - LOGGER.error("Error while loading file '{}'.".format(songbook_path)) - sys.exit(1) - - songbook = add_songbook_defaults(user_songbook) + songbook = open_songbook(songbook_path) - songbook['_filepath'] = os.path.abspath(songbook_path) - sbdir = os.path.dirname(songbook['_filepath']) - - # Gathering datadirs - datadirs = [] - if options.datadir: # Command line options - datadirs += [item[0] for item in options.datadir] - if 'book' in songbook and 'datadir' in songbook['book']: - if isinstance(songbook['book']['datadir'], str): - songbook['book']['datadir'] = [songbook['book']['datadir']] - datadirs += [ - os.path.join(sbdir, path) - for path in songbook['book']['datadir'] - ] - del songbook['book']['datadir'] - - # Default value - datadirs.append(sbdir) - songbook['_datadir'] = datadirs - songbook['_cache'] = options.cache[0] + if options.datadir: + for datadir in reversed(options.datadir): + songbook['datadir'].insert(0, datadir) + songbook['_cache'] = options.cache[0] + songbook['_error'] = options.error[0] - try: - sb_builder = SongbookBuilder(songbook, basename) + sb_builder = SongbookBuilder(songbook) sb_builder.unsafe = True sb_builder.build_steps(options.steps) @@ -190,30 +171,5 @@ def main(): sys.exit(0) -def add_songbook_defaults(user_songbook): - """ Adds the defaults values to the songbook if missing from - the user songbook - - Priority: - - User values - - Default values of the user lang (if set) - - Default english values - """ - - # Merge the default and user configs - locale_default = config_model('default') - # Initialize with default in english - default_songbook = locale_default.get('en', {}) - default_songbook = DictOfDict(default_songbook) - - if 'lang' in user_songbook.get('book', []): - # Update default with current lang - lang = user_songbook['book']['lang'] - default_songbook.update(locale_default.get(lang, {})) - # Update default with user_songbook - default_songbook.update(user_songbook) - - return dict(default_songbook) - if __name__ == '__main__': main() diff --git a/patacrep/songs/__init__.py b/patacrep/songs/__init__.py index adf473c5..1a24ff81 100644 --- a/patacrep/songs/__init__.py +++ b/patacrep/songs/__init__.py @@ -185,11 +185,12 @@ class Song: # 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 - ) + with open(self.cached_name, 'wb') as cache_file: + pickle.dump( + cached, + cache_file, + protocol=-1 + ) def __str__(self): return str(self.fullpath) diff --git a/patacrep/songs/chordpro/syntax.py b/patacrep/songs/chordpro/syntax.py index 04104ac6..76d2a0d8 100644 --- a/patacrep/songs/chordpro/syntax.py +++ b/patacrep/songs/chordpro/syntax.py @@ -38,6 +38,8 @@ class ChordproParser(Parser): lexer = ChordProLexer(filename=self.filename) ast.AST.lexer = lexer.lexer parsed = self.parser.parse(content, lexer=lexer.lexer) + if parsed is None: + raise ContentError(message='Fatal error during song parsing.') parsed.error_builders.extend(lexer.error_builders) return parsed @@ -325,8 +327,4 @@ class ChordproParser(Parser): def parse_song(content, filename=None): """Parse song and return its metadata.""" - parser = ChordproParser(filename) - parsed_content = parser.parse(content) - if parsed_content is None: - raise ContentError(message='Fatal error during song parsing.') - return parsed_content + return ChordproParser(filename).parse(content) diff --git a/patacrep/tools/__init__.py b/patacrep/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/patacrep/tools/__main__.py b/patacrep/tools/__main__.py new file mode 100644 index 00000000..669dcec7 --- /dev/null +++ b/patacrep/tools/__main__.py @@ -0,0 +1,114 @@ +#!/bin/env python3 + +"""Command line client to :mod:`tools`""" + +import argparse +import logging +import operator +import os +import pkgutil +import re +import sys + +import patacrep + +# Logging configuration +logging.basicConfig(level=logging.INFO) +LOGGER = logging.getLogger("patatools") + +def _execlp(program, args): + """Call :func:`os.execlp`, adding `program` as the first argument to itself.""" + return os.execlp(program, program, *args) + +def _iter_subcommands(): + """Iterate over subcommands. + + The objects returned are tuples of: + - the name of the command; + - its description; + - the function to call to execute the subcommand. + """ + subcommands = [] + + # Get python subcommands + path = [os.path.join(item, "patacrep", "tools") for item in sys.path] + prefix = "patacrep.tools." + module_re = re.compile(r'{}(?P[^\.]*)\.__main__'.format(prefix)) + for module_loader, name, _ in pkgutil.walk_packages(path, prefix): + match = module_re.match(name) + if match: + module = module_loader.find_module(match.string).load_module() + if hasattr(module, "SUBCOMMAND_DESCRIPTION"): + subcommands.append(match.groupdict()['subcommand']) + yield ( + match.groupdict()['subcommand'], + getattr(module, "SUBCOMMAND_DESCRIPTION"), + module.main, + ) + +class ArgumentParser(argparse.ArgumentParser): + """Proxy class to circumvent an :mod:`argparse` bug. + + Contrarily to what documented, the `argparse.REMAINDER + `_ `nargs` setting + does not include the remainder arguments if the first one begins with `-`. + + This bug is reperted as `17050 `_. This + class can be deleted once this bug has been fixed. + """ + + def parse_args(self, args=None, namespace=None): + if args is None: + args = sys.argv[1:] + subcommands = [command[0] for command in set(_iter_subcommands())] + if len(args) > 0: + if args[0] in subcommands: + args = [args[0], "--"] + args[1:] + + value = super().parse_args(args, namespace) + + if hasattr(value, 'remainder'): + value.remainder = value.remainder[1:] + return value + + +def commandline_parser(): + """Return a command line parser.""" + + parser = ArgumentParser( + prog="patatools", + description=( + "Miscellaneous tools for patacrep." + ), + formatter_class=argparse.RawTextHelpFormatter, + ) + + parser.add_argument( + '--version', + help='Show version', + action='version', + version='%(prog)s ' + patacrep.__version__ + ) + + subparsers = parser.add_subparsers( + title="Subcommands", + description="List of available subcommands.", + ) + subparsers.required = True + subparsers.dest = "subcommand" + + for command, message, function in sorted(_iter_subcommands(), key=operator.itemgetter(0)): + sub1 = subparsers.add_parser(command, help=message, add_help=False) + sub1.add_argument('remainder', nargs=argparse.REMAINDER) + sub1.set_defaults(function=function) + + return parser + +def main(args): + """Main function""" + parser = commandline_parser() + args = parser.parse_args(args[1:]) + args.function(["patatools-{}".format(args.subcommand)] + args.remainder) + +if __name__ == "__main__": + main(sys.argv) diff --git a/patacrep/tools/cache/__init__.py b/patacrep/tools/cache/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/patacrep/tools/cache/__main__.py b/patacrep/tools/cache/__main__.py new file mode 100644 index 00000000..6bf2cd8e --- /dev/null +++ b/patacrep/tools/cache/__main__.py @@ -0,0 +1,73 @@ +"""`patatools cache` command: cache manipulation.""" + +import argparse +import logging +import os +import shutil +import sys +import textwrap + +from patacrep import errors +from patacrep.songbook import open_songbook + +LOGGER = logging.getLogger("patatools.cache") +SUBCOMMAND_DESCRIPTION = "Perform operations on cache." + +def filename(name): + """Check that argument is an existing, readable file name. + + Return the argument for convenience. + """ + if os.path.isfile(name) and os.access(name, os.R_OK): + return name + raise argparse.ArgumentTypeError("Cannot read file '{}'.".format(name)) + +def commandline_parser(): + """Return a command line parser.""" + + parser = argparse.ArgumentParser( + prog="patatools cache", + description=SUBCOMMAND_DESCRIPTION, + formatter_class=argparse.RawTextHelpFormatter, + ) + + subparsers = parser.add_subparsers( + description="", + dest="command", + ) + subparsers.required = True + + clean = subparsers.add_parser( + "clean", + description="Delete cache.", + help="Delete cache.", + ) + clean.add_argument( + 'songbook', + metavar="SONGBOOK", + help=textwrap.dedent("""Songbook file to be used to look for cache path."""), + type=filename, + ) + clean.set_defaults(command=do_clean) + + return parser + +def do_clean(namespace): + """Execute the `patatools cache clean` command.""" + for datadir in open_songbook(namespace.songbook)['_datadir']: + cachedir = os.path.join(datadir, ".cache") + LOGGER.info("Deleting cache directory '{}'...".format(cachedir)) + if os.path.isdir(cachedir): + shutil.rmtree(cachedir) + +def main(args): + """Main function: run from command line.""" + options = commandline_parser().parse_args(args[1:]) + try: + options.command(options) + except errors.SongbookError as error: + LOGGER.error(str(error)) + sys.exit(1) + +if __name__ == "__main__": + main(sys.argv) diff --git a/patacrep/tools/convert/__init__.py b/patacrep/tools/convert/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/patacrep/songs/convert/__main__.py b/patacrep/tools/convert/__main__.py similarity index 66% rename from patacrep/songs/convert/__main__.py rename to patacrep/tools/convert/__main__.py index 1c2e8cfb..a7b25455 100644 --- a/patacrep/songs/convert/__main__.py +++ b/patacrep/tools/convert/__main__.py @@ -1,41 +1,44 @@ -"""Conversion between formats - -See the :meth:`__usage` method for more information. -""" +"""`patatools.convert` command: convert between song formats""" import os import logging import sys from patacrep import files -from patacrep.songs import DEFAULT_CONFIG +from patacrep.content import ContentError from patacrep.utils import yesno +from patacrep.build import config_model -LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger("patatools.convert") +SUBCOMMAND_DESCRIPTION = "Convert between song formats" -def __usage(): - return "python3 -m patacrep.songs.convert INPUTFORMAT OUTPUTFORMAT FILES" +def _usage(): + return "patatools convert INPUTFORMAT OUTPUTFORMAT FILES" def confirm(destname): + """Ask whether destination name should be overwrited.""" while True: try: return yesno(input("File '{}' already exist. Overwrite? [yn] ".format(destname))) except ValueError: continue -if __name__ == "__main__": - if len(sys.argv) < 4: +def main(args=None): + """Main function: run from command line.""" + if args is None: + args = sys.argv + if len(args) < 4: LOGGER.error("Invalid number of arguments.") - LOGGER.error("Usage: %s", __usage()) + LOGGER.error("Usage: %s", _usage()) sys.exit(1) - source = sys.argv[1] - dest = sys.argv[2] - song_files = sys.argv[3:] + source = args[1] + dest = args[2] + song_files = args[3:] # todo : what is the datadir argument used for? renderers = files.load_plugins( - datadirs=DEFAULT_CONFIG.get('datadir', []), + datadirs=[], root_modules=['songs'], keyword='SONG_RENDERERS', ) @@ -56,8 +59,8 @@ if __name__ == "__main__": sys.exit(1) for file in song_files: - song = renderers[dest][source](file, DEFAULT_CONFIG) try: + song = renderers[dest][source](file, config_model('default')['en']) destname = "{}.{}".format(".".join(file.split(".")[:-1]), dest) if os.path.exists(destname): if not confirm(destname): @@ -65,6 +68,9 @@ if __name__ == "__main__": with open(destname, "w") as destfile: destfile.write(song.render()) + except ContentError: + LOGGER.error("Cannot parse file '%s'.", file) + sys.exit(1) except NotImplementedError: LOGGER.error("Cannot convert to format '%s'.", dest) sys.exit(1) @@ -74,3 +80,6 @@ if __name__ == "__main__": sys.exit(0) sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/patatools b/patatools new file mode 100755 index 00000000..0afec5a7 --- /dev/null +++ b/patatools @@ -0,0 +1,6 @@ +#! /bin/sh + +# Do not edit this file. This file is just a helper file for development test. +# It is not part of the distributed software. + +python -m patacrep.tools $@ diff --git a/setup.py b/setup.py index fd1b48a5..4d0c74c5 100755 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ setup( entry_points={ 'console_scripts': [ "songbook = patacrep.songbook.__main__:main", + "patatools = patacrep.tools.__main__:main", ], }, classifiers=[ diff --git a/songbook b/songbook index e16b8d1a..83887a71 100755 --- a/songbook +++ b/songbook @@ -3,8 +3,4 @@ # Do not edit this file. This file is just a helper file for development test. # It is not part of the distributed software. -if [ "$1" = "purgecache" ]; then - find . -name ".cache" -type d -print -exec rm -r {} + -else - python -m patacrep.songbook "$@" -fi +python -m patacrep.songbook "$@" diff --git a/test/test_patatools/.gitignore b/test/test_patatools/.gitignore new file mode 100644 index 00000000..16d3c4db --- /dev/null +++ b/test/test_patatools/.gitignore @@ -0,0 +1 @@ +.cache diff --git a/test/test_patatools/__init__.py b/test/test_patatools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/test_patatools/test_cache.py b/test/test_patatools/test_cache.py new file mode 100644 index 00000000..447e0e73 --- /dev/null +++ b/test/test_patatools/test_cache.py @@ -0,0 +1,67 @@ +"""Tests of the patatools-cache command.""" + +# pylint: disable=too-few-public-methods + +import os +import shutil +import unittest + +from patacrep.files import chdir +from patacrep.tools.__main__ import main as tools_main +from patacrep.tools.cache.__main__ import main as cache_main +from patacrep.songbook.__main__ import main as songbook_main + +CACHEDIR = os.path.join(os.path.dirname(__file__), "test_cache_datadir", ".cache") + +class TestCache(unittest.TestCase): + """Test of the "patatools cache" subcommand""" + + def setUp(self): + """Remove cache.""" + self._remove_cache() + self.assertFalse(os.path.exists(CACHEDIR)) + + def tearDown(self): + """Remove cache.""" + self._remove_cache() + self.assertFalse(os.path.exists(CACHEDIR)) + + @staticmethod + def _remove_cache(): + """Delete cache.""" + shutil.rmtree(CACHEDIR, ignore_errors=True) + + def _system(self, main, args): + with chdir(os.path.dirname(__file__)): + try: + main(args) + except SystemExit as systemexit: + self.assertEqual(systemexit.code, 0) + + def test_clean_exists(self): + """Test of the "patatools cache clean" subcommand""" + for main, args in [ + (tools_main, ["patatools", "cache", "clean", "test_cache.yaml"]), + (cache_main, ["patatools-cache", "clean", "test_cache.yaml"]), + ]: + with self.subTest(main=main, args=args): + # First compilation. Ensure that cache exists afterwards + self._system(songbook_main, ["songbook", "--steps", "tex,clean", "test_cache.yaml"]) + self.assertTrue(os.path.exists(CACHEDIR)) + + # Clean cache + self._system(main, args) + + # Ensure that cache does not exist + self.assertFalse(os.path.exists(CACHEDIR)) + + def test_clean_not_exists(self): + """Test of the "patatools cache clean" subcommand""" + # Clean non-existent cache + for main, args in [ + (tools_main, ["patatools", "cache", "clean", "test_cache.yaml"]), + (cache_main, ["patatools-cache", "clean", "test_cache.yaml"]), + ]: + with self.subTest(main=main, args=args): + # Clean cache + self._system(main, args) diff --git a/test/test_patatools/test_cache.yaml b/test/test_patatools/test_cache.yaml new file mode 100644 index 00000000..56ab86d3 --- /dev/null +++ b/test/test_patatools/test_cache.yaml @@ -0,0 +1,2 @@ +book: + datadir: test_cache_datadir diff --git a/test/test_patatools/test_cache_datadir/songs/foo.csg b/test/test_patatools/test_cache_datadir/songs/foo.csg new file mode 100644 index 00000000..e69de29b diff --git a/test/test_patatools/test_convert.py b/test/test_patatools/test_convert.py new file mode 100644 index 00000000..f882fe88 --- /dev/null +++ b/test/test_patatools/test_convert.py @@ -0,0 +1,123 @@ +"""Tests of the patatools-convert command.""" + +# pylint: disable=too-few-public-methods + +import contextlib +import glob +import os +import unittest + +from pkg_resources import resource_filename + +from patacrep import files +from patacrep.tools.__main__ import main as tools_main +from patacrep.encoding import open_read +from patacrep.tools.convert.__main__ import main as convert_main + +from .. import dynamic # pylint: disable=unused-import +from .. import logging_reduced + +class TestConvert(unittest.TestCase, metaclass=dynamic.DynamicTest): + """Test of the "patatools convert" subcommand""" + + @staticmethod + def _system(main, args): + try: + main(args) + except SystemExit as systemexit: + return systemexit.code + return 0 + + def assertConvert(self, basename, in_format, out_format): # pylint: disable=invalid-name + """Test of the "patatools convert" subcommand""" + sourcename = "{}.{}".format(basename, in_format) + destname = "{}.{}".format(basename, out_format) + controlname = "{}.{}.control".format(sourcename, out_format) + for main, args in [ + (tools_main, ["patatools", "convert"]), + (convert_main, ["patatools-convert"]), + ]: + with self.subTest(main=main, args=args): + with self.chdir("test_convert_success"): + with open_read(controlname) as controlfile: + with logging_reduced(): + if os.path.exists(destname): + os.remove(destname) + self.assertEqual( + self._system(main, args + [in_format, out_format, sourcename]), + 0, + ) + expected = controlfile.read().strip().replace( + "@TEST_FOLDER@", + files.path2posix(resource_filename(__name__, "")), + ) + with open_read(destname) as destfile: + self.assertMultiLineEqual( + destfile.read().replace('\r\n', '\n').strip(), + expected.strip(), + ) + + def assertFailConvert(self, basename, in_format, out_format): # pylint: disable=invalid-name + """Test of the "patatools convert" subcommand""" + sourcename = "{}.{}".format(basename, in_format) + destname = "{}.{}".format(basename, out_format) + for main, args in [ + (tools_main, ["patatools", "convert"]), + (convert_main, ["patatools-convert"]), + ]: + with self.subTest(main=main, args=args): + with self.chdir("test_convert_failure"): + with logging_reduced(): + if os.path.exists(destname): + os.remove(destname) + self.assertEqual( + self._system(main, args + [in_format, out_format, sourcename]), + 1, + ) + + @staticmethod + @contextlib.contextmanager + def chdir(*pathlist): + """Temporary change current directory, relative to this file directory""" + with files.chdir(resource_filename(__name__, ""), *pathlist): + yield + + @classmethod + def _iter_testmethods(cls): + """Iterate over song files to test.""" + for directory, create_test in [ + ("test_convert_success", cls._create_test_success), + ("test_convert_failure", cls._create_test_failure), + ]: + with cls.chdir(directory): + for control in sorted(glob.glob('*.*.*.control')): + [*base, in_format, out_format, _] = control.split('.') + base = '.'.join(base) + yield ( + "test_{}_{}_{}".format(base, in_format, out_format), + create_test(base, in_format, out_format), + ) + + @classmethod + def _create_test_success(cls, base, in_format, out_format): + """Return a function testing conversion. + + :param str base: Base name of the file to convert. + :param str in_format: Source format. + :param str out_format: Destination format. + """ + test_parse_render = lambda self: self.assertConvert(base, in_format, out_format) + test_parse_render.__doc__ = ( + "Test that '{base}.{in_format}' is correctly converted to '{out_format}'." + ).format(base=os.path.basename(base), in_format=in_format, out_format=out_format) + return test_parse_render + + @classmethod + def _create_test_failure(cls, base, in_format, out_format): + """Return a function testing failing conversions + """ + test_parse_render = lambda self: self.assertFailConvert(base, in_format, out_format) + test_parse_render.__doc__ = ( + "Test that '{base}.{in_format}' raises an error when trying to convert it to '{out_format}'." # pylint: disable=line-too-long + ).format(base=os.path.basename(base), in_format=in_format, out_format=out_format) + return test_parse_render diff --git a/test/test_patatools/test_convert_failure/song.csg b/test/test_patatools/test_convert_failure/song.csg new file mode 100644 index 00000000..cb763ba7 --- /dev/null +++ b/test/test_patatools/test_convert_failure/song.csg @@ -0,0 +1,122 @@ +\selectlanguage{english} + +\beginsong{}[ + by={ + }, +] + + +\begin{verse} + \selectlanguage + \songcolumns + \beginsong + \[&y={Traditionnel},cover={traditionnel},al&um={France}] +\end{verse} + + +\begin{verse} + \cover + \gtab + \gtab + \gtab +\end{verse} + + +\begin{verse} + \begin + Cheva\\[C]liers de la Table Ronde + Goûtons \\[G7]voir si le vin est \\[C]bon + \rep + \end +\end{verse} + + +\begin{verse} + \begin + Goûtons \\[F]voir, \echo + Goûtons \\[C]voir, \echo + Goûtons \\[G7]voir si le vin est bon + \rep + \end +\end{verse} + + +\begin{verse} + \begin + S'il est bon, s'il est agréable + J'en boirai jusqu'à mon plaisir + \end +\end{verse} + + +\begin{verse} + \begin + J'en boirai cinq à six bouteilles + Et encore, ce n'est pas beaucoup + \end +\end{verse} + + +\begin{verse} + \begin + Si je meurs, je veux qu'on m'enterre + Dans une cave où il y a du bon vin + \end +\end{verse} + + +\begin{verse} + \begin + Les deux pieds contre la muraille + Et la tête sous le robinet + \end +\end{verse} + + +\begin{verse} + \begin + Et les quatre plus grands ivrognes + Porteront les quatre coins du drap + \end +\end{verse} + + +\begin{verse} + \begin + Pour donner le discours d'usage + On prendra le bistrot du coin + \end +\end{verse} + + +\begin{verse} + \begin + Et si le tonneau se débouche + J'en boirai jusqu'à mon plaisir + \end +\end{verse} + + +\begin{verse} + \begin + Et s'il en reste quelques gouttes + Ce sera pour nous rafraîchir + \end +\end{verse} + + +\begin{verse} + \begin + Sur ma tombe, je veux qu'on inscrive + \emph + \end +\end{verse} + + +\begin{verse} + \endsong +\end{verse} + + + +\endsong \ No newline at end of file diff --git a/test/test_patatools/test_convert_failure/song.csg.tsg.control b/test/test_patatools/test_convert_failure/song.csg.tsg.control new file mode 100644 index 00000000..e69de29b diff --git a/test/test_patatools/test_convert_success/.gitignore b/test/test_patatools/test_convert_success/.gitignore new file mode 100644 index 00000000..9947c79b --- /dev/null +++ b/test/test_patatools/test_convert_success/.gitignore @@ -0,0 +1 @@ +greensleeves.tsg diff --git a/test/test_patatools/test_convert_success/greensleeves.csg b/test/test_patatools/test_convert_success/greensleeves.csg new file mode 100644 index 00000000..54f8911f --- /dev/null +++ b/test/test_patatools/test_convert_success/greensleeves.csg @@ -0,0 +1,45 @@ +{lang: en} +{columns: 2} +{title: Greensleeves} +{title: Un autre sous-titre} +{title: Un sous titre} +{artist: Traditionnel} +{album: Angleterre} + + +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/test/test_patatools/test_convert_success/greensleeves.csg.tsg.control b/test/test_patatools/test_convert_success/greensleeves.csg.tsg.control new file mode 100644 index 00000000..cba09096 --- /dev/null +++ b/test/test_patatools/test_convert_success/greensleeves.csg.tsg.control @@ -0,0 +1,62 @@ +\selectlanguage{english} +\songcolumns{2} + +\beginsong{Greensleeves\\ +Un autre sous-titre\\ +Un sous titre}[ + by={ + Traditionnel }, + album={Angleterre}, +] + + + + +\begin{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{verse} + + +\begin{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{chorus} + + +\begin{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{verse} + + +\begin{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{verse} + + +\begin{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{verse} + + +\begin{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{verse} + +\endsong diff --git a/test/test_song/test_parser.py b/test/test_song/test_parser.py index bef2e83b..39f4a05c 100644 --- a/test/test_song/test_parser.py +++ b/test/test_song/test_parser.py @@ -48,8 +48,7 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest): @staticmethod @contextlib.contextmanager def chdir(*path): - """Context to temporarry change current directory, relative to this file directory - """ + """Temporary change current directory, relative to this file directory""" with files.chdir(resource_filename(__name__, ""), *path): yield