Browse Source

Merge pull request #75 from patacrep/tox

Using tox for tests
pull/76/head
Luthaf 10 years ago
parent
commit
688f6c70ac
  1. 2
      .gitignore
  2. 9
      .travis.yml
  3. 13
      README.rst
  4. 2
      Requirements.txt
  5. 58
      patacrep/authors.py
  6. 84
      patacrep/build.py
  7. 8
      patacrep/content/cwd.py
  8. 12
      patacrep/content/include.py
  9. 24
      patacrep/content/section.py
  10. 27
      patacrep/content/song.py
  11. 12
      patacrep/content/songsection.py
  12. 8
      patacrep/content/sorted.py
  13. 10
      patacrep/content/tex.py
  14. 10
      patacrep/encoding.py
  15. 29
      patacrep/errors.py
  16. 30
      patacrep/files.py
  17. 34
      patacrep/index.py
  18. 12
      patacrep/latex/ast.py
  19. 8
      patacrep/latex/detex.py
  20. 54
      patacrep/latex/lexer.py
  21. 39
      patacrep/latex/syntax.py
  22. 111
      patacrep/songbook.py
  23. 54
      patacrep/songs/__init__.py
  24. 16
      patacrep/songs/chordpro/__init__.py
  25. 36
      patacrep/songs/chordpro/ast.py
  26. 2
      patacrep/songs/chordpro/lexer.py
  27. 50
      patacrep/songs/chordpro/syntax.py
  28. 4
      patacrep/songs/chordpro/test/test_parser.py
  29. 8
      patacrep/songs/latex/__init__.py
  30. 33
      patacrep/songs/syntax.py
  31. 69
      patacrep/templates.py
  32. 5
      pylintrc
  33. 15
      tox.ini

2
.gitignore

@ -13,3 +13,5 @@ dist
*.pdf *.pdf
*.pyc *.pyc
*.tar.gz *.tar.gz
.eggs
.tox

9
.travis.yml

@ -0,0 +1,9 @@
git:
depth: 1
language: python
python:
- 3.4
install:
- pip install tox
script:
- tox

13
README.rst

@ -1,6 +1,8 @@
Patacrep, a songbook compilation chain Patacrep, a songbook compilation chain
====================================== ======================================
|sources| |build| |pypi| |documentation| |license|
This package provides a compilation toolchain that produce LaTeX This package provides a compilation toolchain that produce LaTeX
songbook using the LaTeX songs package. A new LaTeX document class is songbook using the LaTeX songs package. A new LaTeX document class is
provided to allow specific customisation and new command like embedded provided to allow specific customisation and new command like embedded
@ -61,3 +63,14 @@ Contact & Forums
---------------- ----------------
* http://www.patacrep.com/forum * http://www.patacrep.com/forum
.. |documentation| image:: http://readthedocs.org/projects/patacrep/badge
:target: http://patacrep.readthedocs.org
.. |pypi| image:: https://img.shields.io/pypi/v/patacrep.svg
:target: http://pypi.python.org/pypi/patacrep
.. |license| image:: https://img.shields.io/pypi/l/patacrep.svg
:target: http://www.gnu.org/licenses/gpl-2.0.html
.. |sources| image:: https://img.shields.io/badge/sources-patacrep-brightgreen.svg
:target: http://github.com/patacrep/patacrep
.. |build| image:: https://travis-ci.org/patacrep/patacrep.svg?branch=master
:target: https://travis-ci.org/patacrep/patacrep

2
Requirements.txt

@ -1,4 +1,4 @@
ply
Jinja2==2.7.3 Jinja2==2.7.3
argparse==1.2.1
chardet==2.2.1 chardet==2.2.1
unidecode>=0.04.16 unidecode>=0.04.16

58
patacrep/authors.py

@ -6,10 +6,10 @@ import re
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
DEFAULT_AUTHWORDS = { DEFAULT_AUTHWORDS = {
"after": ["by"], "after": ["by"],
"ignore": ["unknown"], "ignore": ["unknown"],
"sep": ["and"], "sep": ["and"],
} }
def compile_authwords(authwords): def compile_authwords(authwords):
"""Convert strings of authwords to compiled regular expressions. """Convert strings of authwords to compiled regular expressions.
@ -23,13 +23,13 @@ def compile_authwords(authwords):
# Compilation # Compilation
authwords['after'] = [ authwords['after'] = [
re.compile(r"^.*\b%s\b(.*)$" % word, re.LOCALE) re.compile(r"^.*\b%s\b(.*)$" % word, re.LOCALE)
for word in authwords['after'] for word in authwords['after']
] ]
authwords['sep'] = [ authwords['sep'] = [
re.compile(r"^(.*)%s +(.*)$" % word, re.LOCALE) re.compile(r"^(.*)%s +(.*)$" % word, re.LOCALE)
for word in ([" %s" % word for word in authwords['sep']] + [',']) for word in ([" %s" % word for word in authwords['sep']] + [','])
] ]
return authwords return authwords
@ -153,11 +153,11 @@ def processauthors_clean_authors(authors_list):
See docstring of processauthors() for more information. See docstring of processauthors() for more information.
""" """
return [ return [
author.lstrip() author.lstrip()
for author for author
in authors_list in authors_list
if author.lstrip() if author.lstrip()
] ]
def processauthors(authors_string, after=None, ignore=None, sep=None): def processauthors(authors_string, after=None, ignore=None, sep=None):
r"""Return a list of authors r"""Return a list of authors
@ -210,20 +210,20 @@ def processauthors(authors_string, after=None, ignore=None, sep=None):
ignore = [] ignore = []
return [ return [
split_author_names(author) split_author_names(author)
for author for author
in processauthors_clean_authors( in processauthors_clean_authors(
processauthors_ignore_authors( processauthors_ignore_authors(
processauthors_remove_after( processauthors_remove_after(
processauthors_split_string( processauthors_split_string(
processauthors_removeparen( processauthors_removeparen(
authors_string authors_string
), ),
sep), sep),
after), after),
ignore) ignore)
) )
] ]
def process_listauthors(authors_list, after=None, ignore=None, sep=None): def process_listauthors(authors_list, after=None, ignore=None, sep=None):
"""Process a list of authors, and return the list of resulting authors.""" """Process a list of authors, and return the list of resulting authors."""

84
patacrep/build.py

@ -17,23 +17,23 @@ LOGGER = logging.getLogger(__name__)
EOL = "\n" EOL = "\n"
DEFAULT_STEPS = ['tex', 'pdf', 'sbx', 'pdf', 'clean'] DEFAULT_STEPS = ['tex', 'pdf', 'sbx', 'pdf', 'clean']
GENERATED_EXTENSIONS = [ GENERATED_EXTENSIONS = [
"_auth.sbx", "_auth.sbx",
"_auth.sxd", "_auth.sxd",
".aux", ".aux",
".log", ".log",
".out", ".out",
".sxc", ".sxc",
".tex", ".tex",
"_title.sbx", "_title.sbx",
"_title.sxd", "_title.sxd",
] ]
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
'template': "default.tex", 'template': "default.tex",
'lang': 'english', 'lang': 'english',
'content': [], 'content': [],
'titleprefixwords': [], 'titleprefixwords': [],
'encoding': None, 'encoding': None,
} }
@ -67,16 +67,16 @@ class Songbook(object):
abs_datadir.append(os.path.abspath(path)) abs_datadir.append(os.path.abspath(path))
else: else:
LOGGER.warning( LOGGER.warning(
"Ignoring non-existent datadir '{}'.".format(path) "Ignoring non-existent datadir '{}'.".format(path)
) )
abs_datadir.append(__DATADIR__) abs_datadir.append(__DATADIR__)
self.config['datadir'] = abs_datadir self.config['datadir'] = abs_datadir
self.config['_songdir'] = [ self.config['_songdir'] = [
DataSubpath(path, 'songs') DataSubpath(path, 'songs')
for path in self.config['datadir'] for path in self.config['datadir']
] ]
def write_tex(self, output): def write_tex(self, output):
"""Build the '.tex' file corresponding to self. """Build the '.tex' file corresponding to self.
@ -88,17 +88,17 @@ class Songbook(object):
config = DEFAULT_CONFIG.copy() config = DEFAULT_CONFIG.copy()
config.update(self.config) config.update(self.config)
renderer = TexBookRenderer( renderer = TexBookRenderer(
config['template'], config['template'],
config['datadir'], config['datadir'],
config['lang'], config['lang'],
config['encoding'], config['encoding'],
) )
config.update(renderer.get_variables()) config.update(renderer.get_variables())
config.update(self.config) config.update(self.config)
config['_compiled_authwords'] = authors.compile_authwords( config['_compiled_authwords'] = authors.compile_authwords(
copy.deepcopy(config['authwords']) copy.deepcopy(config['authwords'])
) )
# Loading custom plugins # Loading custom plugins
config['_content_plugins'] = files.load_plugins( config['_content_plugins'] = files.load_plugins(
@ -115,9 +115,9 @@ class Songbook(object):
# Configuration set # Configuration set
config['render_content'] = content.render_content config['render_content'] = content.render_content
config['content'] = content.process_content( config['content'] = content.process_content(
config.get('content', []), config.get('content', []),
config, config,
) )
config['filename'] = output.name[:-4] config['filename'] = output.name[:-4]
renderer.render_tex(output, config) renderer.render_tex(output, config)
@ -166,8 +166,8 @@ class SongbookBuilder(object):
self._pdflatex_options.append("-halt-on-error") self._pdflatex_options.append("-halt-on-error")
for datadir in self.songbook.config["datadir"]: for datadir in self.songbook.config["datadir"]:
self._pdflatex_options.append( self._pdflatex_options.append(
'--include-directory="{}"'.format(datadir) '--include-directory="{}"'.format(datadir)
) )
def build_steps(self, steps=None): def build_steps(self, steps=None):
"""Perform steps on the songbook by calling relevant self.build_*() """Perform steps on the songbook by calling relevant self.build_*()
@ -204,8 +204,8 @@ class SongbookBuilder(object):
"""Build .tex file from templates""" """Build .tex file from templates"""
LOGGER.info("Building '{}.tex'".format(self.basename)) LOGGER.info("Building '{}.tex'".format(self.basename))
with codecs.open( with codecs.open(
"{}.tex".format(self.basename), 'w', 'utf-8', "{}.tex".format(self.basename), 'w', 'utf-8',
) as output: ) as output:
self.songbook.write_tex(output) self.songbook.write_tex(output)
def build_pdf(self): def build_pdf(self):
@ -215,13 +215,13 @@ class SongbookBuilder(object):
try: try:
process = Popen( process = Popen(
["pdflatex"] + self._pdflatex_options + [self.basename], ["pdflatex"] + self._pdflatex_options + [self.basename],
stdin=PIPE, stdin=PIPE,
stdout=PIPE, stdout=PIPE,
stderr=PIPE, stderr=PIPE,
env=os.environ, env=os.environ,
universal_newlines=True, universal_newlines=True,
) )
except Exception as error: except Exception as error:
LOGGER.debug(error) LOGGER.debug(error)
raise errors.LatexCompilationError(self.basename) raise errors.LatexCompilationError(self.basename)

8
patacrep/content/cwd.py

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

12
patacrep/content/include.py

@ -26,9 +26,9 @@ def load_from_datadirs(path, config=None):
return filepath return filepath
# File not found # File not found
raise ContentError( raise ContentError(
"include", "include",
errors.notfound(path, config.get("datadir", [])), errors.notfound(path, config.get("datadir", [])),
) )
#pylint: disable=unused-argument #pylint: disable=unused-argument
def parse(keyword, config, argument, contentlist): def parse(keyword, config, argument, contentlist):
@ -47,9 +47,9 @@ def parse(keyword, config, argument, contentlist):
content_file = None content_file = None
try: try:
with encoding.open_read( with encoding.open_read(
filepath, filepath,
encoding=config['encoding'] encoding=config['encoding']
) as content_file: ) as content_file:
new_content = json.load(content_file) new_content = json.load(content_file)
except Exception as error: # pylint: disable=broad-except except Exception as error: # pylint: disable=broad-except
LOGGER.error(error) LOGGER.error(error)

24
patacrep/content/section.py

@ -3,14 +3,14 @@
from patacrep.content import Content, ContentError from patacrep.content import Content, ContentError
KEYWORDS = [ KEYWORDS = [
"part", "part",
"chapter", "chapter",
"section", "section",
"subsection", "subsection",
"subsubsection", "subsubsection",
"paragraph", "paragraph",
"subparagraph", "subparagraph",
] ]
FULL_KEYWORDS = KEYWORDS + ["{}*".format(word) for word in KEYWORDS] FULL_KEYWORDS = KEYWORDS + ["{}*".format(word) for word in KEYWORDS]
class Section(Content): class Section(Content):
@ -43,12 +43,12 @@ def parse(keyword, argument, contentlist, config):
""" """
if (keyword not in KEYWORDS) and (len(contentlist) != 1): if (keyword not in KEYWORDS) and (len(contentlist) != 1):
raise ContentError( raise ContentError(
keyword, keyword,
"Starred section names must have exactly one argument." "Starred section names must have exactly one argument."
) )
if (len(contentlist) not in [1, 2]): if (len(contentlist) not in [1, 2]):
raise ContentError(keyword, "Section can have one or two arguments.") raise ContentError(keyword, "Section can have one or two arguments.")
return [Section(keyword, *contentlist)] #pylint: disable=star-args return [Section(keyword, *contentlist)]
CONTENT_PLUGINS = dict([ CONTENT_PLUGINS = dict([

27
patacrep/content/song.py

@ -67,7 +67,8 @@ def parse(keyword, argument, contentlist, config):
LOGGER.debug('Parsing file "{}"'.format(filename)) LOGGER.debug('Parsing file "{}"'.format(filename))
extension = filename.split(".")[-1] extension = filename.split(".")[-1]
if extension not in plugins: if extension not in plugins:
LOGGER.warning(( LOGGER.warning(
(
'I do not know how to parse "{}": name does ' 'I do not know how to parse "{}": name does '
'not end with one of {}. Ignored.' 'not end with one of {}. Ignored.'
).format( ).format(
@ -76,10 +77,10 @@ def parse(keyword, argument, contentlist, config):
)) ))
continue continue
renderer = SongRenderer(plugins[extension]( renderer = SongRenderer(plugins[extension](
songdir.datadir, songdir.datadir,
filename, filename,
config, config,
)) ))
songlist.append(renderer) songlist.append(renderer)
config["_languages"].update(renderer.song.languages) config["_languages"].update(renderer.song.languages)
if len(songlist) > before: if len(songlist) > before:
@ -105,9 +106,9 @@ class OnlySongsError(ContentError):
def __str__(self): def __str__(self):
return ( return (
"Only songs are allowed, and the following items are not:" + "Only songs are allowed, and the following items are not:" +
str(self.not_songs) str(self.not_songs)
) )
def process_songs(content, config=None): def process_songs(content, config=None):
"""Process content that containt only songs. """Process content that containt only songs.
@ -117,11 +118,11 @@ def process_songs(content, config=None):
""" """
contentlist = process_content(content, config) contentlist = process_content(content, config)
not_songs = [ not_songs = [
item item
for item for item
in contentlist in contentlist
if not isinstance(item, SongRenderer) if not isinstance(item, SongRenderer)
] ]
if not_songs: if not_songs:
raise OnlySongsError(not_songs) raise OnlySongsError(not_songs)
return contentlist return contentlist

12
patacrep/content/songsection.py

@ -3,9 +3,9 @@
from patacrep.content import Content, ContentError from patacrep.content import Content, ContentError
KEYWORDS = [ KEYWORDS = [
"songchapter", "songchapter",
"songsection", "songsection",
] ]
class SongSection(Content): class SongSection(Content):
"""A songsection or songchapter.""" """A songsection or songchapter."""
@ -31,9 +31,9 @@ def parse(keyword, argument, contentlist, config):
""" """
if (keyword not in KEYWORDS) and (len(contentlist) != 1): if (keyword not in KEYWORDS) and (len(contentlist) != 1):
raise ContentError( raise ContentError(
keyword, keyword,
"Starred section names must have exactly one argument.", "Starred section names must have exactly one argument.",
) )
return [SongSection(keyword, contentlist[0])] return [SongSection(keyword, contentlist[0])]

8
patacrep/content/sorted.py

@ -56,11 +56,11 @@ def key_generator(sort):
field = song.data[key] field = song.data[key]
except KeyError: except KeyError:
LOGGER.debug( LOGGER.debug(
"Ignoring unknown key '{}' for song {}.".format( "Ignoring unknown key '{}' for song {}.".format(
key, key,
files.relpath(song.fullpath), files.relpath(song.fullpath),
)
) )
)
field = "" field = ""
songkey.append(normalize_field(field)) songkey.append(normalize_field(field))
return songkey return songkey

10
patacrep/content/tex.py

@ -32,8 +32,8 @@ def parse(keyword, argument, contentlist, config):
""" """
if not contentlist: if not contentlist:
LOGGER.warning( LOGGER.warning(
"Useless 'tex' content: list of files to include is empty." "Useless 'tex' content: list of files to include is empty."
) )
filelist = [] filelist = []
basefolders = [path.fullpath for path in config['_songdir']] +\ basefolders = [path.fullpath for path in config['_songdir']] +\
config['datadir'] + \ config['datadir'] + \
@ -48,9 +48,11 @@ def parse(keyword, argument, contentlist, config):
)) ))
break break
if not checked_file: if not checked_file:
LOGGER.warning("{} Compilation may fail later.".format( LOGGER.warning(
errors.notfound(filename, basefolders)) "{} Compilation may fail later.".format(
errors.notfound(filename, basefolders)
) )
)
continue continue
filelist.append(LaTeX(checked_file)) filelist.append(LaTeX(checked_file))

10
patacrep/encoding.py

@ -21,9 +21,9 @@ def open_read(filename, mode='r', encoding=None):
fileencoding = encoding fileencoding = encoding
with codecs.open( with codecs.open(
filename, filename,
mode=mode, mode=mode,
encoding=fileencoding, encoding=fileencoding,
errors='replace', errors='replace',
) as fileobject: ) as fileobject:
yield fileobject yield fileobject

29
patacrep/errors.py

@ -39,9 +39,10 @@ class LatexCompilationError(SongbookError):
self.basename = basename self.basename = basename
def __str__(self): def __str__(self):
return ("""Error while pdfLaTeX compilation of "{basename}.tex" """ return (
"""(see {basename}.log for more information).""" """Error while pdfLaTeX compilation of "{basename}.tex" """
).format(basename=self.basename) """(see {basename}.log for more information)."""
).format(basename=self.basename)
class StepCommandError(SongbookError): class StepCommandError(SongbookError):
"""Error during custom command compilation.""" """Error during custom command compilation."""
@ -65,9 +66,9 @@ class CleaningError(SongbookError):
def __str__(self): def __str__(self):
return """Error while removing "{filename}": {exception}.""".format( return """Error while removing "{filename}": {exception}.""".format(
filename=self.filename, filename=self.filename,
exception=str(self.exception) exception=str(self.exception)
) )
class UnknownStep(SongbookError): class UnknownStep(SongbookError):
"""Unknown compilation step.""" """Unknown compilation step."""
@ -79,6 +80,16 @@ class UnknownStep(SongbookError):
def __str__(self): def __str__(self):
return """Compilation step "{step}" unknown.""".format(step=self.step) return """Compilation step "{step}" unknown.""".format(step=self.step)
class ParsingError(SongbookError):
"""Parsing error."""
def __init__(self, message):
super().__init__(self)
self.message = message
def __str__(self):
return self.message
def notfound(filename, paths, message=None): def notfound(filename, paths, message=None):
"""Return a string saying that file was not found in paths.""" """Return a string saying that file was not found in paths."""
if message is None: if message is None:
@ -87,6 +98,6 @@ def notfound(filename, paths, message=None):
#pylint: disable=expression-not-assigned #pylint: disable=expression-not-assigned
[unique_paths.append(item) for item in paths if item not in unique_paths] [unique_paths.append(item) for item in paths if item not in unique_paths]
return message.format( return message.format(
name=filename, name=filename,
paths=", ".join(['"{}"'.format(item) for item in unique_paths]), paths=", ".join(['"{}"'.format(item) for item in unique_paths]),
) )

30
patacrep/files.py

@ -49,8 +49,9 @@ def path2posix(string):
return string[0:-1] return string[0:-1]
(head, tail) = os.path.split(string) (head, tail) = os.path.split(string)
return posixpath.join( return posixpath.join(
path2posix(head), path2posix(head),
tail) tail,
)
@contextmanager @contextmanager
def chdir(path): def chdir(path):
@ -87,24 +88,23 @@ def load_plugins(datadirs, root_modules, keyword):
- keys are the keywords ; - keys are the keywords ;
- values are functions triggered when this keyword is met. - values are functions triggered when this keyword is met.
""" """
# pylint: disable=star-args
plugins = {} plugins = {}
directory_list = ( directory_list = (
[ [
os.path.join(datadir, "python", *root_modules) os.path.join(datadir, "python", *root_modules)
for datadir in datadirs for datadir in datadirs
] ]
+ [os.path.join( + [os.path.join(
os.path.dirname(__file__), os.path.dirname(__file__),
*root_modules *root_modules
)] )]
) )
for directory in directory_list: for directory in directory_list:
if not os.path.exists(directory): if not os.path.exists(directory):
LOGGER.debug( LOGGER.debug(
"Ignoring non-existent directory '%s'.", "Ignoring non-existent directory '%s'.",
directory directory
) )
continue continue
for (dirpath, __ignored, filenames) in os.walk(directory): for (dirpath, __ignored, filenames) in os.walk(directory):
modules = ["patacrep"] + root_modules modules = ["patacrep"] + root_modules

34
patacrep/index.py

@ -105,12 +105,12 @@ class Index(object):
self.data[first] = dict() self.data[first] = dict()
if not key in self.data[first]: if not key in self.data[first]:
self.data[first][key] = { self.data[first][key] = {
'sortingkey': [ 'sortingkey': [
unidecode.unidecode(tex2plain(item)).lower() unidecode.unidecode(tex2plain(item)).lower()
for item in key for item in key
], ],
'entries': [], 'entries': [],
} }
self.data[first][key]['entries'].append({'num': number, 'link': link}) self.data[first][key]['entries'].append({'num': number, 'link': link})
def add(self, key, number, link): def add(self, key, number, link):
@ -124,13 +124,13 @@ class Index(object):
match = pattern.match(key) match = pattern.match(key)
if match: if match:
self._raw_add( self._raw_add(
( (
(match.group(2) + match.group(3)).strip(), (match.group(2) + match.group(3)).strip(),
match.group(1).strip(), match.group(1).strip(),
), ),
number, number,
link link
) )
return return
self._raw_add((key, ""), number, link) self._raw_add((key, ""), number, link)
@ -179,10 +179,10 @@ class Index(object):
def sortkey(key): def sortkey(key):
"""Return something sortable for `entries[key]`.""" """Return something sortable for `entries[key]`."""
return [ return [
locale.strxfrm(item) locale.strxfrm(item)
for item for item
in entries[key]['sortingkey'] in entries[key]['sortingkey']
] ]
string = r'\begin{idxblock}{' + letter + '}' + EOL string = r'\begin{idxblock}{' + letter + '}' + EOL
for key in sorted(entries, key=sortkey): for key in sorted(entries, key=sortkey):
string += " " + self.entry_to_str(key, entries[key]['entries']) string += " " + self.entry_to_str(key, entries[key]['entries'])

12
patacrep/latex/ast.py

@ -16,8 +16,8 @@ class AST:
parsing. parsing.
""" """
cls.metadata = { cls.metadata = {
'@languages': set(), '@languages': set(),
} }
class Expression(AST): class Expression(AST):
"""LaTeX expression""" """LaTeX expression"""
@ -50,10 +50,10 @@ class Command(AST):
if self.name in [r'\emph']: if self.name in [r'\emph']:
return str(self.mandatory[0]) return str(self.mandatory[0])
return "{}{}{}".format( return "{}{}{}".format(
self.name, self.name,
"".join(["[{}]".format(item) for item in self.optional]), "".join(["[{}]".format(item) for item in self.optional]),
"".join(["{{{}}}".format(item) for item in self.mandatory]), "".join(["{{{}}}".format(item) for item in self.mandatory]),
) )
class BeginSong(AST): class BeginSong(AST):

8
patacrep/latex/detex.py

@ -105,10 +105,10 @@ def detex(arg):
]) ])
elif isinstance(arg, list): elif isinstance(arg, list):
return [ return [
detex(item) detex(item)
for item for item
in arg in arg
] ]
elif isinstance(arg, set): elif isinstance(arg, set):
return set(detex(list(arg))) return set(detex(list(arg)))
elif isinstance(arg, str): elif isinstance(arg, str):

54
patacrep/latex/lexer.py

@ -7,21 +7,21 @@ LOGGER = logging.getLogger()
#pylint: disable=invalid-name #pylint: disable=invalid-name
tokens = ( tokens = (
'LBRACKET', 'LBRACKET',
'RBRACKET', 'RBRACKET',
'LBRACE', 'LBRACE',
'RBRACE', 'RBRACE',
'COMMAND', 'COMMAND',
'NEWLINE', 'NEWLINE',
'COMMA', 'COMMA',
'EQUAL', 'EQUAL',
'CHARACTER', 'CHARACTER',
'SPACE', 'SPACE',
'BEGINSONG', 'BEGINSONG',
'SONG_LTITLE', 'SONG_LTITLE',
'SONG_RTITLE', 'SONG_RTITLE',
'SONG_LOPTIONS', 'SONG_LOPTIONS',
'SONG_ROPTIONS', 'SONG_ROPTIONS',
) )
class SimpleLexer: class SimpleLexer:
@ -36,18 +36,18 @@ class SimpleLexer:
t_COMMAND = r'\\([@a-zA-Z]+|[^\\])' t_COMMAND = r'\\([@a-zA-Z]+|[^\\])'
t_NEWLINE = r'\\\\' t_NEWLINE = r'\\\\'
SPECIAL_CHARACTERS = ( SPECIAL_CHARACTERS = (
t_LBRACKET + t_LBRACKET +
t_RBRACKET + t_RBRACKET +
t_RBRACE + t_RBRACE +
t_LBRACE + t_LBRACE +
r"\\" + r"\\" +
r" " + r" " +
r"\n" + r"\n" +
r"\r" + r"\r" +
r"%" + r"%" +
r"=" + r"=" +
r"," r","
) )
t_CHARACTER = r'[^{}]'.format(SPECIAL_CHARACTERS) t_CHARACTER = r'[^{}]'.format(SPECIAL_CHARACTERS)
t_EQUAL = r'=' t_EQUAL = r'='
t_COMMA = r',' t_COMMA = r','

39
patacrep/latex/syntax.py

@ -3,52 +3,25 @@
import logging import logging
import ply.yacc as yacc import ply.yacc as yacc
from patacrep.songs.syntax import Parser
from patacrep.latex.lexer import tokens, SimpleLexer, SongLexer from patacrep.latex.lexer import tokens, SimpleLexer, SongLexer
from patacrep.latex import ast from patacrep.latex import ast
from patacrep.errors import SongbookError from patacrep.errors import ParsingError
from patacrep.latex.detex import detex from patacrep.latex.detex import detex
LOGGER = logging.getLogger() LOGGER = logging.getLogger()
class ParsingError(SongbookError):
"""Parsing error."""
def __init__(self, message):
super().__init__(self)
self.message = message
def __str__(self):
return self.message
# pylint: disable=line-too-long # pylint: disable=line-too-long
class Parser: class LatexParser(Parser):
"""LaTeX parser.""" """LaTeX parser."""
def __init__(self, filename=None): def __init__(self, filename=None):
super().__init__()
self.tokens = tokens self.tokens = tokens
self.ast = ast.AST self.ast = ast.AST
self.ast.init_metadata() self.ast.init_metadata()
self.filename = filename 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."""
LOGGER.error(
"Error in file {}, line {} at position {}.".format(
str(self.filename),
token.lineno,
self.__find_column(token),
)
)
@staticmethod @staticmethod
def p_expression(symbols): def p_expression(symbols):
"""expression : brackets expression """expression : brackets expression
@ -238,7 +211,7 @@ def tex2plain(string):
"""Parse string and return its plain text version.""" """Parse string and return its plain text version."""
return detex( return detex(
silent_yacc( silent_yacc(
module=Parser(), module=LatexParser(),
).parse( ).parse(
string, string,
lexer=SimpleLexer().lexer, lexer=SimpleLexer().lexer,
@ -254,7 +227,7 @@ def parse_song(content, filename=None):
display error messages. display error messages.
""" """
return detex( return detex(
silent_yacc(module=Parser(filename)).parse( silent_yacc(module=LatexParser(filename)).parse(
content, content,
lexer=SongLexer().lexer, lexer=SongLexer().lexer,
).metadata ).metadata

111
patacrep/songbook.py

@ -24,13 +24,13 @@ class ParseStepsAction(argparse.Action):
if not getattr(namespace, self.dest): if not getattr(namespace, self.dest):
setattr(namespace, self.dest, []) setattr(namespace, self.dest, [])
setattr( setattr(
namespace, namespace,
self.dest, self.dest,
( (
getattr(namespace, self.dest) getattr(namespace, self.dest)
+ [value.strip() for value in values[0].split(',')] + [value.strip() for value in values[0].split(',')]
), ),
) )
class VerboseAction(argparse.Action): class VerboseAction(argparse.Action):
"""Set verbosity level with option --verbose.""" """Set verbosity level with option --verbose."""
@ -41,40 +41,47 @@ def argument_parser(args):
"""Parse arguments""" """Parse arguments"""
parser = argparse.ArgumentParser(description="A song book compiler") parser = argparse.ArgumentParser(description="A song book compiler")
parser.add_argument('--version', help='Show version', action='version', parser.add_argument(
version='%(prog)s ' + __version__) '--version', help='Show version', action='version',
version='%(prog)s ' + __version__,
parser.add_argument('book', nargs=1, help=textwrap.dedent("""\ )
Book to compile.
""")) parser.add_argument(
'book', nargs=1, help=textwrap.dedent("Book to compile.")
parser.add_argument('--datadir', '-d', nargs='+', type=str, action='append', )
help=textwrap.dedent("""\
Data location. Expected (not necessarily required) parser.add_argument(
subdirectories are 'songs', 'img', 'latex', 'templates'. '--datadir', '-d', nargs='+', type=str, action='append',
""")) help=textwrap.dedent("""\
Data location. Expected (not necessarily required)
parser.add_argument('--verbose', '-v', nargs=0, action=VerboseAction, subdirectories are 'songs', 'img', 'latex', 'templates'.
help=textwrap.dedent("""\ """)
Show details about the compilation process. )
"""))
parser.add_argument(
parser.add_argument('--steps', '-s', nargs=1, type=str, '--verbose', '-v', nargs=0, action=VerboseAction,
action=ParseStepsAction, help=textwrap.dedent("""\
help=textwrap.dedent("""\ Show details about the compilation process.
Steps to run. Default is "{steps}". """)
Available steps are: )
"tex" produce .tex file from templates;
"pdf" compile .tex file; parser.add_argument(
"sbx" compile index files; '--steps', '-s', nargs=1, type=str,
"clean" remove temporary files; action=ParseStepsAction,
any string beginning with '%%' (in this case, it will be run help=textwrap.dedent("""\
in a shell). Several steps (excepted the custom shell Steps to run. Default is "{steps}".
command) can be combinend in one --steps argument, as a Available steps are:
comma separated string. "tex" produce .tex file from templates;
""".format(steps=','.join(DEFAULT_STEPS))), "pdf" compile .tex file;
default=None, "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.
""".format(steps=','.join(DEFAULT_STEPS))),
default=None,
)
options = parser.parse_args(args) options = parser.parse_args(args)
@ -102,9 +109,9 @@ def main():
songbook = json.load(songbook_file) songbook = json.load(songbook_file)
if 'encoding' in songbook: if 'encoding' in songbook:
with patacrep.encoding.open_read( with patacrep.encoding.open_read(
songbook_path, songbook_path,
encoding=songbook['encoding'] encoding=songbook['encoding']
) as songbook_file: ) as songbook_file:
songbook = json.load(songbook_file) songbook = json.load(songbook_file)
except Exception as error: # pylint: disable=broad-except except Exception as error: # pylint: disable=broad-except
LOGGER.error(error) LOGGER.error(error)
@ -121,12 +128,12 @@ def main():
if isinstance(songbook['datadir'], str): if isinstance(songbook['datadir'], str):
songbook['datadir'] = [songbook['datadir']] songbook['datadir'] = [songbook['datadir']]
datadirs += [ datadirs += [
os.path.join( os.path.join(
os.path.dirname(os.path.abspath(songbook_path)), os.path.dirname(os.path.abspath(songbook_path)),
path path
) )
for path in songbook['datadir'] for path in songbook['datadir']
] ]
# Default value # Default value
datadirs.append(os.path.dirname(os.path.abspath(songbook_path))) datadirs.append(os.path.dirname(os.path.abspath(songbook_path)))
@ -141,8 +148,8 @@ def main():
LOGGER.error(error) LOGGER.error(error)
if LOGGER.level >= logging.INFO: if LOGGER.level >= logging.INFO:
LOGGER.error( LOGGER.error(
"Running again with option '-v' may give more information." "Running again with option '-v' may give more information."
) )
sys.exit(1) sys.exit(1)
except KeyboardInterrupt: except KeyboardInterrupt:
LOGGER.warning("Aborted by user.") LOGGER.warning("Aborted by user.")

54
patacrep/songs/__init__.py

@ -85,16 +85,16 @@ class Song(Content):
# List of attributes to cache # List of attributes to cache
cached_attributes = [ cached_attributes = [
"titles", "titles",
"unprefixed_titles", "unprefixed_titles",
"cached", "cached",
"data", "data",
"subpath", "subpath",
"languages", "languages",
"authors", "authors",
"_filehash", "_filehash",
"_version", "_version",
] ]
def __init__(self, datadir, subpath, config): def __init__(self, datadir, subpath, config):
self.fullpath = os.path.join(datadir, subpath) self.fullpath = os.path.join(datadir, subpath)
@ -105,8 +105,8 @@ class Song(Content):
if datadir: if datadir:
# Only songs in datadirs are cached # Only songs in datadirs are cached
self._filehash = hashlib.md5( self._filehash = hashlib.md5(
open(self.fullpath, 'rb').read() open(self.fullpath, 'rb').read()
).hexdigest() ).hexdigest()
if os.path.exists(cached_name(datadir, subpath)): if os.path.exists(cached_name(datadir, subpath)):
try: try:
cached = pickle.load(open( cached = pickle.load(open(
@ -116,7 +116,7 @@ class Song(Content):
if ( if (
cached['_filehash'] == self._filehash cached['_filehash'] == self._filehash
and cached['_version'] == self.CACHE_VERSION and cached['_version'] == self.CACHE_VERSION
): ):
for attribute in self.cached_attributes: for attribute in self.cached_attributes:
setattr(self, attribute, cached[attribute]) setattr(self, attribute, cached[attribute])
return return
@ -135,17 +135,17 @@ class Song(Content):
self.datadir = datadir self.datadir = datadir
self.subpath = subpath self.subpath = subpath
self.unprefixed_titles = [ self.unprefixed_titles = [
unprefixed_title( unprefixed_title(
title, title,
config['titleprefixwords'] config['titleprefixwords']
)
for title
in self.titles
]
self.authors = process_listauthors(
self.authors,
**config["_compiled_authwords"]
) )
for title
in self.titles
]
self.authors = process_listauthors(
self.authors,
**config["_compiled_authwords"]
)
# Cache management # Cache management
self._version = self.CACHE_VERSION self._version = self.CACHE_VERSION
@ -158,10 +158,10 @@ class Song(Content):
for attribute in self.cached_attributes: for attribute in self.cached_attributes:
cached[attribute] = getattr(self, attribute) cached[attribute] = getattr(self, attribute)
pickle.dump( pickle.dump(
cached, cached,
open(cached_name(self.datadir, self.subpath), 'wb'), open(cached_name(self.datadir, self.subpath), 'wb'),
protocol=-1 protocol=-1
) )
def __repr__(self): def __repr__(self):
return repr((self.titles, self.data, self.fullpath)) return repr((self.titles, self.data, self.fullpath))

16
patacrep/songs/chordpro/__init__.py

@ -25,8 +25,8 @@ class ChordproSong(Song):
self.languages = song.get_directives('language') self.languages = song.get_directives('language')
self.data = dict([meta.as_tuple for meta in song.meta]) self.data = dict([meta.as_tuple for meta in song.meta])
self.cached = { self.cached = {
'song': song, 'song': song,
} }
def tex(self, output): def tex(self, output):
context = { context = {
@ -40,8 +40,8 @@ class ChordproSong(Song):
"render": self.render_tex, "render": self.render_tex,
} }
self.texenv = Environment(loader=FileSystemLoader(os.path.join( self.texenv = Environment(loader=FileSystemLoader(os.path.join(
os.path.abspath(pkg_resources.resource_filename(__name__, 'data')), os.path.abspath(pkg_resources.resource_filename(__name__, 'data')),
'latex' 'latex'
))) )))
return self.render_tex(context, self.cached['song'].content, template="chordpro.tex") return self.render_tex(context, self.cached['song'].content, template="chordpro.tex")
@ -55,10 +55,10 @@ class ChordproSong(Song):
if template is None: if template is None:
template = content.template('tex') template = content.template('tex')
return TexRenderer( return TexRenderer(
template=template, template=template,
encoding='utf8', encoding='utf8',
texenv=self.texenv, texenv=self.texenv,
).template.render(context) ).template.render(context)
SONG_PARSERS = { SONG_PARSERS = {
'sgc': ChordproSong, 'sgc': ChordproSong,

36
patacrep/songs/chordpro/ast.py

@ -146,9 +146,9 @@ class Verse(AST):
def __str__(self): def __str__(self):
return '{{start_of_{type}}}\n{content}\n{{end_of_{type}}}'.format( return '{{start_of_{type}}}\n{content}\n{{end_of_{type}}}'.format(
type=self.type, type=self.type,
content=_indent("\n".join([str(line) for line in self.lines])), content=_indent("\n".join([str(line) for line in self.lines])),
) )
class Chorus(Verse): class Chorus(Verse):
"""Chorus""" """Chorus"""
@ -182,10 +182,10 @@ class Song(AST):
#: Some directives have to be processed before being considered. #: Some directives have to be processed before being considered.
PROCESS_DIRECTIVE = { PROCESS_DIRECTIVE = {
"cov": "_process_relative", "cov": "_process_relative",
"partition": "_process_relative", "partition": "_process_relative",
"image": "_process_relative", "image": "_process_relative",
} }
def __init__(self, filename): def __init__(self, filename):
super().__init__() super().__init__()
@ -242,12 +242,12 @@ class Song(AST):
def __str__(self): def __str__(self):
return ( return (
"\n".join(self.str_meta()).strip() "\n".join(self.str_meta()).strip()
+ +
"\n========\n" "\n========\n"
+ +
"\n".join([str(item) for item in self.content]).strip() "\n".join([str(item) for item in self.content]).strip()
) )
def add_title(self, __ignored, title): def add_title(self, __ignored, title):
@ -372,9 +372,9 @@ class Directive(AST):
def __str__(self): def __str__(self):
if self.argument is not None: if self.argument is not None:
return "{{{}: {}}}".format( return "{{{}: {}}}".format(
self.keyword, self.keyword,
self.argument, self.argument,
) )
else: else:
return "{{{}}}".format(self.keyword) return "{{{}}}".format(self.keyword)
@ -405,6 +405,6 @@ class Tab(AST):
def __str__(self): def __str__(self):
return '{{start_of_tab}}\n{}\n{{end_of_tab}}'.format( return '{{start_of_tab}}\n{}\n{{end_of_tab}}'.format(
_indent("\n".join(self.content)), _indent("\n".join(self.content)),
) )

2
patacrep/songs/chordpro/lexer.py

@ -39,7 +39,7 @@ class ChordProLexer:
t_SPACE = r'[ \t]+' t_SPACE = r'[ \t]+'
t_chord_CHORD = r'[A-G7#m]+' # TODO This can be refined t_chord_CHORD = r'[A-G7#m]+'
t_directive_SPACE = r'[ \t]+' t_directive_SPACE = r'[ \t]+'
t_directive_KEYWORD = r'[a-zA-Z_]+' t_directive_KEYWORD = r'[a-zA-Z_]+'

50
patacrep/songs/chordpro/syntax.py

@ -1,54 +1,24 @@
# -*- coding: utf-8 -*-
"""ChordPro parser""" """ChordPro parser"""
import logging import logging
import ply.yacc as yacc import ply.yacc as yacc
from patacrep.errors import SongbookError from patacrep.songs.syntax import Parser
from patacrep.songs.chordpro import ast from patacrep.songs.chordpro import ast
from patacrep.songs.chordpro.lexer import tokens, ChordProLexer from patacrep.songs.chordpro.lexer import tokens, ChordProLexer
LOGGER = logging.getLogger() LOGGER = logging.getLogger()
class ParsingError(SongbookError): class ChordproParser(Parser):
"""Parsing error."""
def __init__(self, message):
super().__init__(self)
self.message = message
def __str__(self):
return self.message
class Parser:
"""ChordPro parser class""" """ChordPro parser class"""
start = "song" start = "song"
def __init__(self, filename=None): def __init__(self, filename=None):
super().__init__()
self.tokens = tokens self.tokens = tokens
self.filename = filename 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): def p_song(self, symbols):
"""song : block song """song : block song
| empty | empty
@ -208,10 +178,10 @@ class Parser:
def parse_song(content, filename=None): def parse_song(content, filename=None):
"""Parse song and return its metadata.""" """Parse song and return its metadata."""
return yacc.yacc( return yacc.yacc(
module=Parser(filename), module=ChordproParser(filename),
debug=0, debug=0,
write_tables=0, write_tables=0,
).parse( ).parse(
content, content,
lexer=ChordProLexer().lexer, lexer=ChordProLexer().lexer,
) )

4
patacrep/songs/chordpro/test/test_parser.py

@ -49,8 +49,8 @@ def load_tests(__loader, tests, __pattern):
"""Load several tests given test files present in the directory.""" """Load several tests given test files present in the directory."""
# Load all txt files as tests # Load all txt files as tests
for txt in sorted(glob.glob(os.path.join( for txt in sorted(glob.glob(os.path.join(
os.path.dirname(__file__), os.path.dirname(__file__),
'*.txt', '*.txt',
))): ))):
tests.addTest(ParserTxtRenderer(basename=txt[:-len('.txt')])) tests.addTest(ParserTxtRenderer(basename=txt[:-len('.txt')]))
return tests return tests

8
patacrep/songs/latex/__init__.py

@ -28,10 +28,10 @@ class LatexSong(Song):
def tex(self, output): def tex(self, output):
"""Return the LaTeX code rendering the song.""" """Return the LaTeX code rendering the song."""
return r'\input{{{}}}'.format(files.path2posix( return r'\input{{{}}}'.format(files.path2posix(
files.relpath( files.relpath(
self.fullpath, self.fullpath,
os.path.dirname(output) os.path.dirname(output)
))) )))
SONG_PARSERS = { SONG_PARSERS = {
'is': LatexSong, 'is': LatexSong,

33
patacrep/songs/syntax.py

@ -0,0 +1,33 @@
"""Generic parsing classes and methods"""
import logging
LOGGER = logging.getLogger()
class Parser:
"""Parser class"""
# pylint: disable=too-few-public-methods
def __init__(self):
self.filename = "" # Will be overloaded
@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),
)
)

69
patacrep/templates.py

@ -20,7 +20,8 @@ _LATEX_SUBS = (
(re.compile(r'\.\.\.+'), r'\\ldots'), (re.compile(r'\.\.\.+'), r'\\ldots'),
) )
_VARIABLE_REGEXP = re.compile(r""" _VARIABLE_REGEXP = re.compile(
r"""
\(\*\ *variables\ *\*\) # Match (* variables *) \(\*\ *variables\ *\*\) # Match (* variables *)
( # Match and capture the following: ( # Match and capture the following:
(?: # Start of non-capturing group, used to match a single character (?: # Start of non-capturing group, used to match a single character
@ -51,9 +52,9 @@ class VariablesExtension(Extension):
def parse(self, parser): def parse(self, parser):
next(parser.stream) next(parser.stream)
parser.parse_statements( parser.parse_statements(
end_tokens=['name:endvariables'], end_tokens=['name:endvariables'],
drop_needle=True, drop_needle=True,
) )
return nodes.Const("") # pylint: disable=no-value-for-parameter return nodes.Const("") # pylint: disable=no-value-for-parameter
@ -101,29 +102,31 @@ class TexBookRenderer(TexRenderer):
''' '''
self.lang = lang self.lang = lang
# Load templates in filesystem ... # Load templates in filesystem ...
loaders = [FileSystemLoader(os.path.join(datadir, 'templates')) loaders = [
for datadir in datadirs] FileSystemLoader(os.path.join(datadir, 'templates'))
for datadir in datadirs
]
texenv = Environment( texenv = Environment(
loader=ChoiceLoader(loaders), loader=ChoiceLoader(loaders),
extensions=[VariablesExtension], extensions=[VariablesExtension],
) )
try: try:
super().__init__(template, texenv, encoding) super().__init__(template, texenv, encoding)
except TemplateNotFound as exception: except TemplateNotFound as exception:
# Only works if all loaders are FileSystemLoader(). # Only works if all loaders are FileSystemLoader().
paths = [ paths = [
item item
for loader in self.texenv.loader.loaders for loader in self.texenv.loader.loaders
for item in loader.searchpath for item in loader.searchpath
] ]
raise errors.TemplateError( raise errors.TemplateError(
exception, exception,
errors.notfound( errors.notfound(
exception.name, exception.name,
paths, paths,
message='Template "{name}" not found in {paths}.' message='Template "{name}" not found in {paths}.'
), ),
) )
def get_variables(self): def get_variables(self):
'''Get and return a dictionary with the default values '''Get and return a dictionary with the default values
@ -175,11 +178,11 @@ class TexBookRenderer(TexRenderer):
if subtemplate in skip: if subtemplate in skip:
continue continue
variables.update( variables.update(
self.get_template_variables( self.get_template_variables(
subtemplate, subtemplate,
skip + templates skip + templates
)
) )
)
variables.update(current) variables.update(current)
return variables return variables
@ -210,16 +213,16 @@ class TexBookRenderer(TexRenderer):
subvariables.update(json.loads(var)) subvariables.update(json.loads(var))
except ValueError as exception: except ValueError as exception:
raise errors.TemplateError( raise errors.TemplateError(
exception, exception,
( (
"Error while parsing json in file " "Error while parsing json in file "
"{filename}. The json string was:" "{filename}. The json string was:"
"\n'''\n{jsonstring}\n'''" "\n'''\n{jsonstring}\n'''"
).format( ).format(
filename=templatename, filename=templatename,
jsonstring=var, jsonstring=var,
)
) )
)
return (subvariables, subtemplates) return (subvariables, subtemplates)

5
pylintrc

@ -0,0 +1,5 @@
[VARIABLES]
dummy-variables-rgx=_|dummy
[MESSAGES CONTROL]
disable= logging-format-interpolation

15
tox.ini

@ -0,0 +1,15 @@
# To perform those tests, install `tox` and run "tox" from this directory.
[tox]
# Uncomment to use more python versions
#envlist = py26, py27, py32, py34, lint
envlist = py34, lint
[testenv]
commands = {envpython} setup.py test
deps =
[testenv:lint]
basepython=python3.4
deps=pylint
commands=pylint patacrep --rcfile=pylintrc
Loading…
Cancel
Save