Browse Source

Merge pull request #190 from patacrep/yaml_content

Content uses Yaml system
pull/198/head
Louis 9 years ago
parent
commit
7e06c1c6a0
  1. 6
      .appveyor.yml
  2. 2
      examples/example-all.sb
  3. 4
      examples/example-all.yaml.sb
  4. 590
      patacrep/Rx.py
  5. 17
      patacrep/build.py
  6. 110
      patacrep/content/__init__.py
  7. 37
      patacrep/content/cwd.py
  8. 28
      patacrep/content/include.py
  9. 41
      patacrep/content/section.py
  10. 19
      patacrep/content/song.py
  11. 23
      patacrep/content/songsection.py
  12. 57
      patacrep/content/sort.py
  13. 28
      patacrep/content/tex.py
  14. 2
      patacrep/data/templates/patacrep.tex
  15. 3
      patacrep/data/templates/songbook_model.yml
  16. 2
      patacrep/data/templates/songs.tex
  17. 12
      patacrep/errors.py
  18. 10
      patacrep/songbook/__main__.py
  19. 105
      patacrep/templates.py
  20. 10
      patacrep/utils.py
  21. 2
      test/test_content/cwd.control
  22. 4
      test/test_content/cwd.source
  23. 3
      test/test_content/cwd_list.control
  24. 12
      test/test_content/cwd_list.source
  25. 6
      test/test_content/datadir/songs/custom_list.yaml
  26. 5
      test/test_content/datadir_sort/path1_title1_author1.csg
  27. 5
      test/test_content/datadir_sort/path1_title1_author2.csg
  28. 5
      test/test_content/datadir_sort/path1_title2_author1.csg
  29. 5
      test/test_content/datadir_sort/path1_title2_author2.csg
  30. 5
      test/test_content/datadir_sort/path2_title1_author1.csg
  31. 5
      test/test_content/datadir_sort/path2_title1_author2.csg
  32. 5
      test/test_content/datadir_sort/path2_title2_author1.csg
  33. 5
      test/test_content/datadir_sort/path2_title2_author2.csg
  34. 2
      test/test_content/glob.control
  35. 2
      test/test_content/glob.source
  36. 7
      test/test_content/include.control
  37. 4
      test/test_content/include.source
  38. 1
      test/test_content/invalid.control
  39. 4
      test/test_content/invalid.source
  40. 12
      test/test_content/sections.control
  41. 22
      test/test_content/sections.source
  42. 1
      test/test_content/sections_short.control
  43. 6
      test/test_content/sections_short.source
  44. 5
      test/test_content/songs.control
  45. 9
      test/test_content/songs.source
  46. 7
      test/test_content/songsection.control
  47. 12
      test/test_content/songsection.source
  48. 27
      test/test_content/sort.control
  49. 15
      test/test_content/sort.source
  50. 1
      test/test_content/sorted.control
  51. 1
      test/test_content/sorted.source
  52. 24
      test/test_content/test_content.py
  53. 3
      test/test_content/tex.control
  54. 5
      test/test_content/tex.source
  55. 27
      test/test_songbook/content.sb
  56. 2
      test/test_songbook/content_datadir/songs/include.sbc
  57. 1
      test/test_songbook/datadir.sb
  58. 1
      test/test_songbook/languages.sb
  59. 1
      test/test_songbook/syntax.sb
  60. 1
      test/test_songbook/unicode.sb
  61. 4
      texlive_packages.txt

6
.appveyor.yml

@ -23,7 +23,7 @@ install:
- "python -c \"import struct; print(struct.calcsize('P') * 8)\"" - "python -c \"import struct; print(struct.calcsize('P') * 8)\""
# Download miktex portable (if not cached) # Download miktex portable (if not cached)
- ps: "If (!(Test-Path miktex-portable.exe)){wget http://mirrors.ctan.org/systems/win32/miktex/setup/miktex-portable-2.9.5719.exe -OutFile ./miktex-portable.exe}" - ps: "If (!(Test-Path miktex-portable.exe)){wget http://mirrors.ctan.org/systems/win32/miktex/setup/miktex-portable-2.9.5857.exe -OutFile ./miktex-portable.exe}"
# Unzip miktex portable # Unzip miktex portable
- "7z x miktex-portable.exe * -aot -omiktex > nul" - "7z x miktex-portable.exe * -aot -omiktex > nul"
@ -32,8 +32,8 @@ install:
- cmd: set PATH=%PATH%;C:\projects\patacrep\miktex\miktex\bin - cmd: set PATH=%PATH%;C:\projects\patacrep\miktex\miktex\bin
# Update some packages to prevent ltluatex bug # Update some packages to prevent ltluatex bug
- cmd: mpm.exe --update=miktex-bin-2.9 # - cmd: mpm.exe --update=miktex-bin-2.9 --verbose
- cmd: mpm.exe --update=ltxbase --update=luatexbase --update=luaotfload --update=miktex-luatex-base --update=fontspec # - cmd: mpm.exe --update=ltxbase --update=luatexbase --update=luaotfload --update=miktex-luatex-base --update=fontspec
# Manually install required texlive packages # Manually install required texlive packages
- cmd: mpm.exe --install-some texlive_packages.txt - cmd: mpm.exe --install-some texlive_packages.txt

2
examples/example-all.sb

@ -14,6 +14,6 @@
"sep" : ["and", "et"] "sep" : ["and", "et"]
}, },
"datadir" : ".", "datadir" : ".",
"content": [["sorted"]] "content": [["sort"]]
} }

4
examples/example-all.yaml.sb

@ -13,7 +13,7 @@ authors:
separators: separators:
- "and" - "and"
- "et" - "et"
content: [["sorted"]] content: {"sort"}
template: template:
patacrep.tex: patacrep.tex:
@ -23,4 +23,4 @@ template:
bgcolor: bgcolor:
note: D1E4AE note: D1E4AE
songnumber: AED1E4 songnumber: AED1E4
index: E4AED1 #not enough songs to see it index: E4AED1 #not enough songs to see it

590
patacrep/Rx.py

@ -9,85 +9,242 @@ import re
import types import types
from numbers import Number from numbers import Number
### Exception Classes --------------------------------------------------------
core_types = [ ]
class SchemaError(Exception): class SchemaError(Exception):
pass pass
class SchemaMismatch(Exception): class SchemaMismatch(Exception):
pass
class SchemaTypeMismatch(SchemaMismatch): def __init__(self, message, schema, error=None):
def __init__(self, name, desired_type): Exception.__init__(self, message)
SchemaMismatch.__init__(self, '{0} must be {1}'.format(name, desired_type)) self.type = schema.subname()
self.error = error
class TypeMismatch(SchemaMismatch):
def __init__(self, schema, data):
message = 'must be of type {} (was {})'.format(
schema.subname(),
type(data).__name__
)
class SchemaValueMismatch(SchemaMismatch): SchemaMismatch.__init__(self, message, schema, 'type')
def __init__(self, name, value): self.expected_type = schema.subname()
SchemaMismatch.__init__(self, '{0} must equal {1}'.format(name, value)) self.value = type(data).__name__
class SchemaRangeMismatch(SchemaMismatch):
pass
def indent(text, level=1, whitespace=' '): class ValueMismatch(SchemaMismatch):
return '\n'.join(whitespace*level+line for line in text.split('\n'))
def __init__(self, schema, data):
class Util(object): message = 'must equal {} (was {})'.format(
@staticmethod repr(schema.value),
def make_range_check(opt): repr(data)
)
if not {'min', 'max', 'min-ex', 'max-ex'}.issuperset(opt):
raise ValueError("illegal argument to make_range_check") SchemaMismatch.__init__(self, message, schema, 'value')
if {'min', 'min-ex'}.issubset(opt): self.expected_value = schema.value
raise ValueError("Cannot define both exclusive and inclusive min") self.value = data
if {'max', 'max-ex'}.issubset(opt):
raise ValueError("Cannot define both exclusive and inclusive max")
r = opt.copy() class RangeMismatch(SchemaMismatch):
inf = float('inf')
def __init__(self, schema, data):
def check_range(value):
return( message = 'must be in range {} (was {})'.format(
r.get('min', -inf) <= value and \ schema.range,
r.get('max', inf) >= value and \ data
r.get('min-ex', -inf) < value and \ )
r.get('max-ex', inf) > value
SchemaMismatch.__init__(self, message, schema, 'range')
self.range = schema.range
self.value = data
class LengthRangeMismatch(SchemaMismatch):
def __init__(self, schema, data):
length_range = Range(schema.length)
if not hasattr(length_range, 'min') and \
not hasattr(length_range, 'min_ex'):
length_range.min = 0
message = 'length must be in range {} (was {})'.format(
length_range,
len(data)
)
SchemaMismatch.__init__(self, message, schema, 'range')
self.range = schema.length
self.value = len(data)
class MissingFieldMismatch(SchemaMismatch):
def __init__(self, schema, fields):
if len(fields) == 1:
message = 'missing required field: {}'.format(
repr(fields[0])
) )
else:
message = 'missing required fields: {}'.format(
', '.join(fields)
)
if len(message) >= 80: # if the line is too long
message = 'missing required fields:\n{}'.format(
_indent('\n'.join(fields))
)
return check_range SchemaMismatch.__init__(self, message, schema, 'missing')
self.fields = fields
@staticmethod
def make_range_validator(opt):
check_range = Util.make_range_check(opt)
r = opt.copy()
nan = float('nan')
def validate_range(value, name='value'):
if not check_range(value):
if r.get('min', nan) == r.get('max', nan):
msg = '{0} must equal {1}'.format(name, r['min'])
raise SchemaRangeMismatch(msg)
range_str = ''
if 'min' in r:
range_str = '[{0}, '.format(r['min'])
elif 'min-ex' in r:
range_str = '({0}, '.format(r['min-ex'])
else:
range_str = '(-inf, '
if 'max' in r:
range_str += '{0}]'.format(r['max'])
elif 'max-ex' in r:
range_str += '{0})'.format(r['max-ex'])
else:
range_str += 'inf)'
raise SchemaRangeMismatch(name+' must be in range '+range_str)
return validate_range
class UnknownFieldMismatch(SchemaMismatch):
def __init__(self, schema, fields):
if len(fields) == 1:
message = 'unknown field: {}'.format(
repr(fields[0])
)
else:
message = 'unknown fields: {}'.format(
', '.join(fields)
)
if len(message) >= 80: # if the line is too long
message = 'unknown fields:\n{}'.format(
_indent('\n'.join(fields))
)
SchemaMismatch.__init__(self, message, schema, 'unexpected')
self.fields = fields
class SeqLengthMismatch(SchemaMismatch):
def __init__(self, schema, data):
expected_length = len(schema.content_schema)
message = 'sequence must have {} element{} (had {})'.format(
expected_length,
's'*(expected_length != 1), # plural
len(data)
)
SchemaMismatch.__init__(self, message, schema, 'size')
self.expected_length = expected_length
self.value = len(data)
class TreeMismatch(SchemaMismatch):
def __init__(self, schema, errors=[], child_errors={}, message=None):
## Create error message
error_messages = []
for err in errors:
error_messages.append(str(err))
for key, err in child_errors.items():
if isinstance(key, int):
index = '[item {}]'.format(key)
else:
index = '{}'.format(repr(key))
if isinstance(err, TreeMismatch) and \
not err.errors and len(err.child_errors) == 1:
template = '{} > {}'
else:
template = '{} {}'
msg = template.format(index, err)
error_messages.append(msg)
if message is None:
message = 'does not match schema'
if len(error_messages) == 1:
msg = error_messages[0]
else:
msg = '{}:\n{}'.format(
message,
_indent('\n'.join(error_messages))
)
SchemaMismatch.__init__(self, msg, schema, 'multiple')
self.errors = errors
self.child_errors = child_errors
def _createTreeMismatch(schema, errors=[], child_errors={}, message=None):
if len(errors) == 1 and not child_errors:
return errors[0]
else:
return TreeMismatch(schema, errors, child_errors, message)
### Utilities ----------------------------------------------------------------
class Range(object):
def __init__(self, opt):
if isinstance(opt, Range):
for attr in ('min', 'max', 'min_ex', 'max_ex'):
if hasattr(opt, attr):
setattr(self, attr, getattr(opt, attr))
else:
if not {'min', 'max', 'min-ex', 'max-ex'}.issuperset(opt):
raise ValueError("illegal argument to make_range_check")
if {'min', 'min-ex'}.issubset(opt):
raise ValueError("Cannot define both exclusive and inclusive min")
if {'max', 'max-ex'}.issubset(opt):
raise ValueError("Cannot define both exclusive and inclusive max")
for boundary in ('min', 'max', 'min-ex', 'max-ex'):
if boundary in opt:
attr = boundary.replace('-', '_')
setattr(self, attr, opt[boundary])
def __call__(self, value):
INF = float('inf')
get = lambda attr, default: getattr(self, attr, default)
return(
get('min', -INF) <= value and \
get('max', INF) >= value and \
get('min_ex', -INF) < value and \
get('max_ex', INF) > value
)
def __str__(self):
if hasattr(self, 'min'):
s = '[{}, '.format(self.min)
elif hasattr(self, 'min_ex'):
s = '({}, '.format(self.min_ex)
else:
s = '(-Inf, '
if hasattr(self, 'max'):
s += '{}]'.format(self.max)
elif hasattr(self, 'max_ex'):
s += '{})'.format(self.max_ex)
else:
s += 'Inf)'
return s
def _indent(text, level=1, whitespace=' '):
return '\n'.join(whitespace*level+line for line in text.split('\n'))
### Schema Factory Class -----------------------------------------------------
class Factory(object): class Factory(object):
def __init__(self, register_core_types=True): def __init__(self, register_core_types=True):
@ -109,20 +266,20 @@ class Factory(object):
m = re.match('^/([-._a-z0-9]*)/([-._a-z0-9]+)$', type_name) m = re.match('^/([-._a-z0-9]*)/([-._a-z0-9]+)$', type_name)
if not m: if not m:
raise ValueError("couldn't understand type name '{0}'".format(type_name)) raise ValueError("couldn't understand type name '{}'".format(type_name))
prefix, suffix = m.groups() prefix, suffix = m.groups()
if prefix not in self.prefix_registry: if prefix not in self.prefix_registry:
raise KeyError( raise KeyError(
"unknown prefix '{0}' in type name '{1}'".format(prefix, type_name) "unknown prefix '{0}' in type name '{}'".format(prefix, type_name)
) )
return self.prefix_registry[ prefix ] + suffix return self.prefix_registry[ prefix ] + suffix
def add_prefix(self, name, base): def add_prefix(self, name, base):
if self.prefix_registry.get(name): if self.prefix_registry.get(name):
raise SchemaError("the prefix '{0}' is already registered".format(name)) raise SchemaError("the prefix '{}' is already registered".format(name))
self.prefix_registry[name] = base; self.prefix_registry[name] = base;
@ -130,13 +287,15 @@ class Factory(object):
t_uri = t.uri() t_uri = t.uri()
if t_uri in self.type_registry: if t_uri in self.type_registry:
raise ValueError("type already registered for {0}".format(t_uri)) raise ValueError("type already registered for {}".format(t_uri))
self.type_registry[t_uri] = t self.type_registry[t_uri] = t
def learn_type(self, uri, schema): def learn_type(self, uri, schema):
if self.type_registry.get(uri): if self.type_registry.get(uri):
raise SchemaError("tried to learn type for already-registered uri {0}".format(uri)) raise SchemaError(
"tried to learn type for already-registered uri {}".format(uri)
)
# make sure schema is valid # make sure schema is valid
# should this be in a try/except? # should this be in a try/except?
@ -153,17 +312,27 @@ class Factory(object):
uri = self.expand_uri(schema['type']) uri = self.expand_uri(schema['type'])
if not self.type_registry.get(uri): raise SchemaError("unknown type {0}".format(uri)) if not self.type_registry.get(uri):
raise SchemaError("unknown type {}".format(uri))
type_class = self.type_registry[uri] type_class = self.type_registry[uri]
if isinstance(type_class, dict): if isinstance(type_class, dict):
if not {'type'}.issuperset(schema): if not {'type'}.issuperset(schema):
raise SchemaError('composed type does not take check arguments'); raise SchemaError('composed type does not take check arguments')
return self.make_schema(type_class['schema']) return self.make_schema(type_class['schema'])
else: else:
return type_class(schema, self) return type_class(schema, self)
std_factory = None
def make_schema(schema):
global std_factory
if std_factory is None:
std_factory = Factory()
return std_factory.make_schema(schema)
### Core Type Base Class -------------------------------------------------
class _CoreType(object): class _CoreType(object):
@classmethod @classmethod
def uri(self): def uri(self):
@ -171,7 +340,7 @@ class _CoreType(object):
def __init__(self, schema, rx): def __init__(self, schema, rx):
if not {'type'}.issuperset(schema): if not {'type'}.issuperset(schema):
raise SchemaError('unknown parameter for //{0}'.format(self.subname())) raise SchemaError('unknown parameter for //{}'.format(self.subname()))
def check(self, value): def check(self, value):
try: try:
@ -180,8 +349,10 @@ class _CoreType(object):
return False return False
return True return True
def validate(self, value, name='value'): def validate(self, value):
raise SchemaMismatch('Tried to validate abstract base schema class') raise SchemaMismatch('Tried to validate abstract base schema class', self)
### Core Schema Types --------------------------------------------------------
class AllType(_CoreType): class AllType(_CoreType):
@staticmethod @staticmethod
@ -191,26 +362,22 @@ class AllType(_CoreType):
if not {'type', 'of'}.issuperset(schema): if not {'type', 'of'}.issuperset(schema):
raise SchemaError('unknown parameter for //all') raise SchemaError('unknown parameter for //all')
if not(schema.get('of') and len(schema.get('of'))): if not schema.get('of'):
raise SchemaError('no alternatives given in //all of') raise SchemaError('no alternatives given in //all of')
self.alts = [rx.make_schema(s) for s in schema['of']] self.alts = [rx.make_schema(s) for s in schema['of']]
def validate(self, value, name='value'): def validate(self, value):
error_messages = [] errors = []
for schema in self.alts: for schema in self.alts:
try: try:
schema.validate(value, name) schema.validate(value)
except SchemaMismatch as e: except SchemaMismatch as e:
error_messages.append(str(e)) errors.append(e)
if errors:
raise _createTreeMismatch(self, errors)
if len(error_messages) > 1:
messages = indent('\n'.join(error_messages))
message = '{0} failed to meet all schema requirements:\n{1}'
message = message.format(name, messages)
raise SchemaMismatch(message)
elif len(error_messages) == 1:
raise SchemaMismatch(error_messages[0])
class AnyType(_CoreType): class AnyType(_CoreType):
@staticmethod @staticmethod
@ -223,25 +390,28 @@ class AnyType(_CoreType):
raise SchemaError('unknown parameter for //any') raise SchemaError('unknown parameter for //any')
if 'of' in schema: if 'of' in schema:
if not schema['of']: raise SchemaError('no alternatives given in //any of') if not schema['of']:
raise SchemaError('no alternatives given in //any of')
self.alts = [ rx.make_schema(alt) for alt in schema['of'] ] self.alts = [ rx.make_schema(alt) for alt in schema['of'] ]
def validate(self, value, name='value'): def validate(self, value):
if self.alts is None: if self.alts is None:
return return
error_messages = []
errors = []
for schema in self.alts: for schema in self.alts:
try: try:
schema.validate(value, name) schema.validate(value)
break break
except SchemaMismatch as e: except SchemaMismatch as e:
error_messages.append(str(e)) errors.append(e)
if len(errors) == len(self.alts):
message = 'must satisfy at least one of the following'
raise _createTreeMismatch(self, errors, message=message)
if len(error_messages) == len(self.alts):
messages = indent('\n'.join(error_messages))
message = '{0} failed to meet any schema requirements:\n{1}'
message = message.format(name, messages)
raise SchemaMismatch(message)
class ArrType(_CoreType): class ArrType(_CoreType):
@staticmethod @staticmethod
@ -259,47 +429,45 @@ class ArrType(_CoreType):
self.content_schema = rx.make_schema(schema['contents']) self.content_schema = rx.make_schema(schema['contents'])
if schema.get('length'): if schema.get('length'):
self.length = Util.make_range_validator(schema['length']) self.length = Range(schema['length'])
def validate(self, value, name='value'): def validate(self, value):
if not isinstance(value, (list, tuple)): if not isinstance(value, (list, tuple)):
raise SchemaTypeMismatch(name, 'array') raise TypeMismatch(self, value)
if self.length: errors = []
self.length(len(value), name+' length') if self.length and not self.length(len(value)):
err = LengthRangeMismatch(self, value)
errors.append(err)
error_messages = [] child_errors = {}
for i, item in enumerate(value): for key, item in enumerate(value):
try: try:
self.content_schema.validate(item, 'item '+str(i)) self.content_schema.validate(item)
except SchemaMismatch as e: except SchemaMismatch as e:
error_messages.append(str(e)) child_errors[key] = e
if errors or child_errors:
raise _createTreeMismatch(self, errors, child_errors)
if len(error_messages) > 1:
messages = indent('\n'.join(error_messages))
message = '{0} sequence contains invalid elements:\n{1}'
message = message.format(name, messages)
raise SchemaMismatch(message)
elif len(error_messages) == 1:
raise SchemaMismatch(name+': '+error_messages[0])
class BoolType(_CoreType): class BoolType(_CoreType):
@staticmethod @staticmethod
def subname(): return 'bool' def subname(): return 'bool'
def validate(self, value, name='value'): def validate(self, value,):
if not isinstance(value, bool): if not isinstance(value, bool):
raise SchemaTypeMismatch(name, 'boolean') raise TypeMismatch(self, value)
class DefType(_CoreType): class DefType(_CoreType):
@staticmethod @staticmethod
def subname(): return 'def' def subname(): return 'def'
def validate(self, value):
def validate(self, value, name='value'):
if value is None: if value is None:
raise SchemaMismatch(name+' must be non-null') raise TypeMismatch(self, value)
class FailType(_CoreType): class FailType(_CoreType):
@staticmethod @staticmethod
@ -307,8 +475,13 @@ class FailType(_CoreType):
def check(self, value): return False def check(self, value): return False
def validate(self, value, name='value'): def validate(self, value):
raise SchemaMismatch(name+' is of fail type, automatically invalid.') raise SchemaMismatch(
'is of fail type, automatically invalid.',
self,
'fail'
)
class IntType(_CoreType): class IntType(_CoreType):
@staticmethod @staticmethod
@ -326,17 +499,18 @@ class IntType(_CoreType):
self.range = None self.range = None
if 'range' in schema: if 'range' in schema:
self.range = Util.make_range_validator(schema['range']) self.range = Range(schema['range'])
def validate(self, value, name='value'): def validate(self, value):
if not isinstance(value, Number) or isinstance(value, bool) or value%1: if not isinstance(value, Number) or isinstance(value, bool) or value%1:
raise SchemaTypeMismatch(name,'integer') raise TypeMismatch(self, value)
if self.range: if self.range and not self.range(value):
self.range(value, name) raise RangeMismatch(self, value)
if self.value is not None and value != self.value: if self.value is not None and value != self.value:
raise SchemaValueMismatch(name, self.value) raise ValueMismatch(self, value)
class MapType(_CoreType): class MapType(_CoreType):
@staticmethod @staticmethod
@ -353,25 +527,21 @@ class MapType(_CoreType):
self.value_schema = rx.make_schema(schema['values']) self.value_schema = rx.make_schema(schema['values'])
def validate(self, value, name='value'): def validate(self, value):
if not isinstance(value, dict): if not isinstance(value, dict):
raise SchemaTypeMismatch(name, 'map') raise TypeMismatch(self, value)
error_messages = [] child_errors = {}
for key, val in value.items(): for key, val in value.items():
try: try:
self.value_schema.validate(val, key) self.value_schema.validate(val)
except SchemaMismatch as e: except SchemaMismatch as e:
error_messages.append(str(e)) child_errors[key] = e
if child_errors:
raise _createTreeMismatch(self, child_errors=child_errors)
if len(error_messages) > 1:
messages = indent('\n'.join(error_messages))
message = '{0} map contains invalid entries:\n{1}'
message = message.format(name, messages)
raise SchemaMismatch(message)
elif len(error_messages) == 1:
raise SchemaMismatch(name+': '+error_messages[0])
class NilType(_CoreType): class NilType(_CoreType):
@staticmethod @staticmethod
@ -379,9 +549,10 @@ class NilType(_CoreType):
def check(self, value): return value is None def check(self, value): return value is None
def validate(self, value, name='value'): def validate(self, value):
if value is not None: if value is not None:
raise SchemaTypeMismatch(name, 'null') raise TypeMismatch(self, value)
class NumType(_CoreType): class NumType(_CoreType):
@staticmethod @staticmethod
@ -400,25 +571,27 @@ class NumType(_CoreType):
self.range = None self.range = None
if schema.get('range'): if schema.get('range'):
self.range = Util.make_range_validator(schema['range']) self.range = Range(schema['range'])
def validate(self, value, name='value'): def validate(self, value):
if not isinstance(value, Number) or isinstance(value, bool): if not isinstance(value, Number) or isinstance(value, bool):
raise SchemaTypeMismatch(name, 'number') raise TypeMismatch(self, value)
if self.range: if self.range and not self.range(value):
self.range(value, name) raise RangeMismatch(self, value)
if self.value is not None and value != self.value: if self.value is not None and value != self.value:
raise SchemaValueMismatch(name, self.value) raise ValueMismatch(self, value)
class OneType(_CoreType): class OneType(_CoreType):
@staticmethod @staticmethod
def subname(): return 'one' def subname(): return 'one'
def validate(self, value, name='value'): def validate(self, value):
if not isinstance(value, (Number, str)): if not isinstance(value, (Number, str)):
raise SchemaTypeMismatch(name, 'number or string') raise TypeMismatch(self, value)
class RecType(_CoreType): class RecType(_CoreType):
@staticmethod @staticmethod
@ -436,7 +609,9 @@ class RecType(_CoreType):
setattr(self, which, {}) setattr(self, which, {})
for field in schema.get(which, {}).keys(): for field in schema.get(which, {}).keys():
if field in self.known: if field in self.known:
raise SchemaError('%s appears in both required and optional' % field) raise SchemaError(
'%s appears in both required and optional' % field
)
self.known.add(field) self.known.add(field)
@ -444,47 +619,53 @@ class RecType(_CoreType):
schema[which][field] schema[which][field]
) )
def validate(self, value, name='value'): def validate(self, value):
if not isinstance(value, dict): if not isinstance(value, dict):
raise SchemaTypeMismatch(name, 'record') raise TypeMismatch(self, value)
unknown = [k for k in value.keys() if k not in self.known] errors = []
child_errors = {}
if unknown and not self.rest_schema: missing_fields = []
fields = indent('\n'.join(unknown))
raise SchemaMismatch(name+' contains unknown fields:\n'+fields)
error_messages = []
for field in self.required: for field in self.required:
try:
if field not in value: if field not in value:
raise SchemaMismatch('missing required field: '+field) missing_fields.append(field)
self.required[field].validate(value[field], field) else:
except SchemaMismatch as e: try:
error_messages.append(str(e)) self.required[field].validate(value[field])
except SchemaMismatch as e:
child_errors[field] = e
if missing_fields:
err = MissingFieldMismatch(self, missing_fields)
errors.append(err)
for field in self.optional: for field in self.optional:
if field not in value: continue if field not in value: continue
try:
self.optional[field].validate(value[field], field)
except SchemaMismatch as e:
error_messages.append(str(e))
if unknown:
rest = {key: value[key] for key in unknown}
try: try:
self.rest_schema.validate(rest, name) self.optional[field].validate(value[field])
except SchemaMismatch as e: except SchemaMismatch as e:
error_messages.append(str(e)) child_errors[field] = e
if len(error_messages) > 1: unknown = [k for k in value.keys() if k not in self.known]
messages = indent('\n'.join(error_messages))
message = '{0} record is invalid:\n{1}' if unknown:
message = message.format(name, messages) if self.rest_schema:
raise SchemaMismatch(message) rest = {key: value[key] for key in unknown}
elif len(error_messages) == 1: try:
raise SchemaMismatch(name+': '+error_messages[0]) self.rest_schema.validate(rest)
except SchemaMismatch as e:
errors.append(e)
else:
fields = _indent('\n'.join(unknown))
err = UnknownFieldMismatch(self, unknown)
errors.append(err)
if errors or child_errors:
raise _createTreeMismatch(self, errors, child_errors)
class SeqType(_CoreType): class SeqType(_CoreType):
@ -504,34 +685,33 @@ class SeqType(_CoreType):
if (schema.get('tail')): if (schema.get('tail')):
self.tail_schema = rx.make_schema(schema['tail']) self.tail_schema = rx.make_schema(schema['tail'])
def validate(self, value, name='value'): def validate(self, value):
if not isinstance(value, (list, tuple)): if not isinstance(value, (list, tuple)):
raise SchemaTypeMismatch(name, 'sequence') raise TypeMismatch(self, value)
if len(value) < len(self.content_schema): errors = []
raise SchemaMismatch(name+' is less than expected length')
if len(value) > len(self.content_schema) and not self.tail_schema: if len(value) != len(self.content_schema):
raise SchemaMismatch(name+' exceeds expected length') if len(value) > len(self.content_schema) and self.tail_schema:
try:
self.tail_schema.validate(value[len(self.content_schema):])
except SchemaMismatch as e:
errors.append(e)
else:
err = SeqLengthMismatch(self, value)
errors.append(err)
error_messages = [] child_errors = {}
for i, (schema, item) in enumerate(zip(self.content_schema, value)): for index, (schema, item) in enumerate(zip(self.content_schema, value)):
try: try:
schema.validate(item, 'item '+str(i)) schema.validate(item)
except SchemaMismatch as e: except SchemaMismatch as e:
error_messages.append(str(e)) child_errors[index] = e
if len(error_messages) > 1: if errors or child_errors:
messages = indent('\n'.join(error_messages)) raise _createTreeMismatch(self, errors, child_errors)
message = '{0} sequence is invalid:\n{1}'
message = message.format(name, messages)
raise SchemaMismatch(message)
elif len(error_messages) == 1:
raise SchemaMismatch(name+': '+error_messages[0])
if len(value) > len(self.content_schema):
self.tail_schema.validate(value[len(self.content_schema):], name)
class StrType(_CoreType): class StrType(_CoreType):
@staticmethod @staticmethod
@ -549,18 +729,20 @@ class StrType(_CoreType):
self.length = None self.length = None
if 'length' in schema: if 'length' in schema:
self.length = Util.make_range_validator(schema['length']) self.length = Range(schema['length'])
def validate(self, value, name='value'): def validate(self, value):
if not isinstance(value, str): if not isinstance(value, str):
raise SchemaTypeMismatch(name, 'string') raise TypeMismatch(self, value)
if self.value is not None and value != self.value: if self.value is not None and value != self.value:
raise SchemaValueMismatch(name, '"{0}"'.format(self.value)) raise ValueMismatch(self, self)
if self.length:
self.length(len(value), name+' length') if self.length and not self.length(len(value)):
raise LengthRangeMismatch(self, value)
core_types = [ core_types = [
AllType, AnyType, ArrType, BoolType, DefType, AllType, AnyType, ArrType, BoolType, DefType,
FailType, IntType, MapType, NilType, NumType, FailType, IntType, MapType, NilType, NumType,
OneType, RecType, SeqType, StrType OneType, RecType, SeqType, StrType
] ]

17
patacrep/build.py

@ -44,7 +44,12 @@ class Songbook:
def __init__(self, raw_songbook, basename): def __init__(self, raw_songbook, basename):
# Validate config # Validate config
schema = config_model('schema') schema = config_model('schema')
utils.validate_yaml_schema(raw_songbook, schema)
try:
utils.validate_yaml_schema(raw_songbook, schema)
except errors.SchemaError as exception:
exception.message = "The songbook file '{}' is not valid\n".format(basename)
raise exception
self._raw_config = raw_songbook self._raw_config = raw_songbook
self.basename = basename self.basename = basename
@ -84,7 +89,13 @@ class Songbook:
self._config['book']['lang'], self._config['book']['lang'],
self._config['book']['encoding'], self._config['book']['encoding'],
) )
self._config['_template'] = renderer.get_all_variables(self._config.get('template', {}))
try:
self._config['_template'] = renderer.get_all_variables(self._config.get('template', {}))
except errors.SchemaError as exception:
exception.message = "The songbook file '{}' is not valid\n{}".format(
self.basename, exception.message)
raise exception
self._config['_compiled_authwords'] = authors.compile_authwords( self._config['_compiled_authwords'] = authors.compile_authwords(
copy.deepcopy(self._config['authors']) copy.deepcopy(self._config['authors'])
@ -110,7 +121,7 @@ class Songbook:
) )
self._config['filename'] = output.name[:-4] self._config['filename'] = output.name[:-4]
self._config['bookoptions'] = iter_bookoptions(self._config) self._config['_bookoptions'] = iter_bookoptions(self._config)
renderer.render_tex(output, self._config) renderer.render_tex(output, self._config)
self._errors.extend(renderer.errors) self._errors.extend(renderer.errors)

110
patacrep/content/__init__.py

@ -17,13 +17,17 @@ dictionary where:
When analysing the content field of the .sb file, when those keywords are When analysing the content field of the .sb file, when those keywords are
met, the corresponding parser is called. met, the corresponding parser is called.
# Keyword examples
- sort
- section*
- cwd
# Parsers # Parsers
A parser is a function which takes as arguments: A parser is a function which takes as arguments:
- keyword: the keyword triggering this function; - keyword: the keyword triggering this function;
- argument: the argument of the keyword (see below); - argument: the argument of the keyword (see below);
- contentlist: the list of content, that is, the part of the list
following the keyword (see example below);
- config: the configuration object of the current songbook. Plugins can - config: the configuration object of the current songbook. Plugins can
change it. change it.
@ -31,30 +35,20 @@ A parser returns a ContentList object (a list of instances of the ContentItem
class), defined in this module (or of subclasses of this class). class), defined in this module (or of subclasses of this class).
Example: When the following piece of content is met Example: When the following piece of content is met
sort:
["sorted(author, @title)", "a_song.sg", "another_song.sg"] key: ["author", "title"]
content:
the parser associated to keyword 'sorted' get the arguments: - "a_song.sg"
- keyword = "sorted" - "another_song.sg"
- argument = "author, @title"
- contentlist = ["a_song.sg", "another_song.sg"] the parser associated to keyword 'sort' get the arguments:
- keyword = "sort"
- argument = {
'key': ["author", "title"],
'content': ["a_song.sg", "another_song.sg"],
}
- config = <the config file of the current songbook>. - config = <the config file of the current songbook>.
# Keyword
A keyword is either an identifier (alphanumeric characters, and underscore),
or such an identifier, with some text surrounded by parenthesis (like a
function definition); this text is called the argument to the keyword.
Examples:
- sorted
- sorted(author, @title)
- cwd(some/path)
If the keyword has an argument, it can be anything, given that it is
surrounded by parenthesis. It is up to the plugin to parse this argument. For
intance, keyword "foo()(( bar()" is a perfectly valid keyword, and the parser
associated to "foo" will get as argument the string ")(( bar(".
# ContentItem class # ContentItem class
The content classes are subclasses of class ContentItem defined in this module. The content classes are subclasses of class ContentItem defined in this module.
@ -72,8 +66,9 @@ import re
import sys import sys
import jinja2 import jinja2
import yaml
from patacrep import files from patacrep import files, Rx
from patacrep.errors import SharedError from patacrep.errors import SharedError
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -228,12 +223,37 @@ def render(context, content):
return rendered return rendered
def validate_parser_argument(raw_schema):
"""Check that the parser argument respects the schema
Will raise `ContentError` if the schema is not respected.
"""
rx_checker = Rx.Factory({"register_core_types": True})
schema = rx_checker.make_schema(yaml.load(raw_schema))
def wrap(parse):
"""Wrap the parse function"""
def wrapped(keyword, argument, config):
"""Check the argument schema before calling the plugin parser"""
try:
schema.validate(argument)
except Rx.SchemaMismatch as exception:
msg = 'Invalid syntax:\n---\n{}---\n{}'.format(
yaml.dump({keyword: argument}, default_flow_style=False),
str(exception)
)
raise ContentError(keyword, msg)
return parse(keyword, argument=argument, config=config)
return wrapped
return wrap
def process_content(content, config=None): def process_content(content, config=None):
"""Process content, and return a list of ContentItem() objects. """Process content, and return a list of ContentItem() objects.
Arguments are: Arguments are:
- content: the content field of the .sb file, which should be a list, and - content: the content field of the .sb file, which should be a nested list
describe what is to be included in the songbook; and describe what is to be included in the songbook;
- config: the configuration dictionary of the current songbook. - config: the configuration dictionary of the current songbook.
Return: a list of ContentItem objects, corresponding to the content to be Return: a list of ContentItem objects, corresponding to the content to be
@ -241,25 +261,25 @@ def process_content(content, config=None):
""" """
contentlist = ContentList() contentlist = ContentList()
plugins = config.get('_content_plugins', {}) plugins = config.get('_content_plugins', {})
keyword_re = re.compile(r'^ *(?P<keyword>[\w\*]*) *(\((?P<argument>.*)\))? *$')
if not content: if not content:
content = [["song"]] content = [{'song': None}]
elif isinstance(content, dict):
content = [content]
for elem in content: for elem in content:
if isinstance(elem, str): if isinstance(elem, str):
elem = ["song", elem] elem = {'song': elem}
try: if isinstance(elem, dict):
match = keyword_re.match(elem[0]).groupdict() for keyword, argument in elem.items():
except AttributeError: try:
contentlist.append_error(ContentError(elem[0], "Cannot parse content type.")) if keyword not in plugins:
continue raise ContentError(keyword, "Unknown content keyword.")
(keyword, argument) = (match['keyword'], match['argument']) contentlist.extend(plugins[keyword](
if keyword not in plugins: keyword,
contentlist.append_error(ContentError(keyword, "Unknown content type.")) argument=argument,
continue config=config,
contentlist.extend(plugins[keyword]( ))
keyword, except ContentError as error:
argument=argument, contentlist.append_error(error)
contentlist=elem[1:], else:
config=config, contentlist.append_error(ContentError(str(elem), "Unknown content type."))
))
return contentlist return contentlist

37
patacrep/content/cwd.py

@ -1,33 +1,46 @@
"""Change base directory before importing songs.""" """Change base directory before importing songs."""
from patacrep.content import process_content import os
from patacrep.content import process_content, validate_parser_argument
from patacrep.songs import DataSubpath from patacrep.songs import DataSubpath
#pylint: disable=unused-argument #pylint: disable=unused-argument
def parse(keyword, config, argument, contentlist): @validate_parser_argument("""
"""Return a list songs included in contentlist, whith a different base path. type: //rec
required:
path: //str
optional:
content: //any
""")
def parse(keyword, config, argument):
"""Return a list songs, whith a different base path.
Arguments: Arguments:
- keyword: unused; - keyword: unused;
- config: the current songbook configuration dictionary; - config: the current songbook configuration dictionary;
- argument: a directory; - argument: a dict containing:
- contentlist: songbook content, that is parsed by path: string specifying the path to use as root;
patacrep.content.process_content(). content: songbook content, that is parsed by
patacrep.content.process_content().
This function adds 'argument' to the directories where songs are searched This function adds 'path' to the directories where songs are searched
for, and then processes the content. for, and then processes the content.
The 'argument' is added: The 'path' is added:
- first as a relative path to the current directory; - first as a relative path to the *.sb file directory;
- then as a relative path to every path already present in - then as a relative path to every path already present in
config['songdir']. config['songdir'].
""" """
subpath = argument['path']
old_songdir = config['_songdir'] old_songdir = config['_songdir']
sbdir = os.path.dirname(config['_filepath'])
config['_songdir'] = ( config['_songdir'] = (
[DataSubpath(".", argument)] + [DataSubpath(sbdir, subpath)] +
[path.clone().join(argument) for path in config['_songdir']] [path.clone().join(subpath) for path in config['_songdir']]
) )
processed_content = process_content(contentlist, config) processed_content = process_content(argument.get('content'), config)
config['_songdir'] = old_songdir config['_songdir'] = old_songdir
return processed_content return processed_content

28
patacrep/content/include.py

@ -1,14 +1,15 @@
"""Include an external list of songs """Include an external list of songs
This plugin provides keyword 'include', used to include an external list of This plugin provides keyword 'include', used to include an external list of
songs in JSON format. songs in JSON or YAML format.
""" """
import json
import os import os
import logging import logging
from patacrep.content import process_content, ContentError, ContentList import yaml
from patacrep.content import process_content, ContentError, ContentList, validate_parser_argument
from patacrep import encoding, errors, files from patacrep import encoding, errors, files
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -28,18 +29,29 @@ def load_from_datadirs(path, datadirs):
) )
#pylint: disable=unused-argument #pylint: disable=unused-argument
def parse(keyword, config, argument, contentlist): @validate_parser_argument("""
type: //any
of:
- type: //str
- type: //arr
contents: //str
""")
def parse(keyword, config, argument):
"""Include an external file content. """Include an external file content.
Arguments: Arguments:
- keyword: the string 'include'; - keyword: the string 'include';
- config: the current songbook configuration dictionary; - config: the current songbook configuration dictionary;
- argument: None; - argument:
- contentlist: a list of file paths to be included. a list of file paths to be included
or a string of the file to include
""" """
new_contentlist = ContentList() new_contentlist = ContentList()
if isinstance(argument, str):
argument = [argument]
for path in contentlist: for path in argument:
try: try:
filepath = load_from_datadirs(path, config['_datadir']) filepath = load_from_datadirs(path, config['_datadir'])
except ContentError as error: except ContentError as error:
@ -51,7 +63,7 @@ def parse(keyword, config, argument, contentlist):
filepath, filepath,
encoding=config['book']['encoding'] encoding=config['book']['encoding']
) as content_file: ) as content_file:
new_content = json.load(content_file) new_content = yaml.load(content_file)
except Exception as error: # pylint: disable=broad-except except Exception as error: # pylint: disable=broad-except
new_contentlist.append_error(ContentError( new_contentlist.append_error(ContentError(
keyword="include", keyword="include",

41
patacrep/content/section.py

@ -1,6 +1,6 @@
"""Allow LaTeX sections (starred or not) as content of a songbook.""" """Allow LaTeX sections (starred or not) as content of a songbook."""
from patacrep.content import ContentItem, ContentError, ContentList, EmptyContentList from patacrep.content import ContentItem, ContentList, validate_parser_argument
KEYWORDS = [ KEYWORDS = [
"part", "part",
@ -23,36 +23,39 @@ class Section(ContentItem):
self.short = short self.short = short
def render(self, __context): def render(self, __context):
if self.short is None: if self.short is None or self.keyword not in KEYWORDS:
return r'\{}{{{}}}'.format(self.keyword, self.name) return r'\{}{{{}}}'.format(self.keyword, self.name)
else: else:
return r'\{}[{}]{{{}}}'.format(self.keyword, self.short, self.name) return r'\{}[{}]{{{}}}'.format(self.keyword, self.short, self.name)
#pylint: disable=unused-argument #pylint: disable=unused-argument
def parse(keyword, argument, contentlist, config): @validate_parser_argument("""
"""Parse the contentlist. type: //any
of:
- type: //str
- type: //rec
required:
name: //str
optional:
short: //str
""")
def parse(keyword, argument, config):
"""Parse the section.
Arguments: Arguments:
- keyword (one of "part", "chapter", "section", ... , "subparagraph", and - keyword (one of "part", "chapter", "section", ... , "subparagraph", and
their starred versions "part*", "chapter*", ... , "subparagraph*"): the their starred versions "part*", "chapter*", ... , "subparagraph*"): the
section to use; section to use;
- argument: unused; - argument:
- contentlist: a list of one or two strings, which are the names (long either a string describing the section name
and short) of the section; or a dict
name: Name of the section
short: Shortname of the section (only for non starred sections)
- config: configuration dictionary of the current songbook. - config: configuration dictionary of the current songbook.
""" """
try: if isinstance(argument, str):
if (keyword not in KEYWORDS) and (len(contentlist) != 1): argument = {'name': argument}
raise ContentError( return ContentList([Section(keyword, **argument)])
keyword,
"Starred section names must have exactly one argument."
)
if (len(contentlist) not in [1, 2]):
raise ContentError(keyword, "Section can have one or two arguments.")
return ContentList([Section(keyword, *contentlist)])
except ContentError as error:
return EmptyContentList(errors=[error])
CONTENT_PLUGINS = dict([ CONTENT_PLUGINS = dict([
(word, parse) (word, parse)

19
patacrep/content/song.py

@ -7,7 +7,7 @@ import textwrap
import jinja2 import jinja2
from patacrep.content import process_content from patacrep.content import process_content, validate_parser_argument
from patacrep.content import ContentError, ContentItem, ContentList from patacrep.content import ContentError, ContentItem, ContentList
from patacrep import files, errors from patacrep import files, errors
@ -58,18 +58,28 @@ class SongRenderer(ContentItem):
return self.song.fullpath < other.song.fullpath return self.song.fullpath < other.song.fullpath
#pylint: disable=unused-argument #pylint: disable=unused-argument
def parse(keyword, argument, contentlist, config): @validate_parser_argument("""
type: //any
of:
- type: //nil
- type: //str
- type: //arr
contents: //str
""")
def parse(keyword, argument, config):
"""Parse data associated with keyword 'song'. """Parse data associated with keyword 'song'.
Arguments: Arguments:
- keyword: unused; - keyword: unused;
- argument: unused; - argument: a list of strings, which are interpreted as regular
- contentlist: a list of strings, which are interpreted as regular
expressions (interpreted using the glob module), referring to songs. expressions (interpreted using the glob module), referring to songs.
- config: the current songbook configuration dictionary. - config: the current songbook configuration dictionary.
Return a list of Song() instances. Return a list of Song() instances.
""" """
contentlist = argument
if isinstance(contentlist, str):
contentlist = [contentlist]
plugins = config['_song_plugins'] plugins = config['_song_plugins']
if '_langs' not in config: if '_langs' not in config:
config['_langs'] = set() config['_langs'] = set()
@ -121,7 +131,6 @@ def parse(keyword, argument, contentlist, config):
)) ))
return sorted(songlist) return sorted(songlist)
CONTENT_PLUGINS = {'song': parse} CONTENT_PLUGINS = {'song': parse}

23
patacrep/content/songsection.py

@ -1,6 +1,6 @@
"""Allow 'songchapter' and 'songsection' as content of a songbook.""" """Allow 'songchapter' and 'songsection' as content of a songbook."""
from patacrep.content import ContentItem, ContentError, ContentList, EmptyContentList from patacrep.content import ContentItem, ContentList, validate_parser_argument
KEYWORDS = [ KEYWORDS = [
"songchapter", "songchapter",
@ -20,25 +20,18 @@ class SongSection(ContentItem):
return r'\{}{{{}}}'.format(self.keyword, self.name) return r'\{}{{{}}}'.format(self.keyword, self.name)
#pylint: disable=unused-argument #pylint: disable=unused-argument
def parse(keyword, argument, contentlist, config): @validate_parser_argument("""
"""Parse the contentlist. //str
""")
def parse(keyword, argument, config):
"""Parse the songsection.
Arguments: Arguments:
- keyword ("songsection" or "songchapter"): the section to use; - keyword ("songsection" or "songchapter"): the section to use;
- argument: unused; - argument: name of the section;
- contentlist: a list of one string, which is the name of the section;
- config: configuration dictionary of the current songbook. - config: configuration dictionary of the current songbook.
""" """
try: return ContentList([SongSection(keyword, argument)])
if (keyword not in KEYWORDS) and (len(contentlist) != 1):
raise ContentError(
keyword,
"Starred section names must have exactly one argument.",
)
return ContentList([SongSection(keyword, contentlist[0])])
except ContentError as error:
return EmptyContentList(errors=[error])
CONTENT_PLUGINS = dict([ CONTENT_PLUGINS = dict([
(keyword, parse) (keyword, parse)

57
patacrep/content/sorted.py → patacrep/content/sort.py

@ -1,6 +1,6 @@
"""Sorted list of songs. """Sorted list of songs.
This plugin provides keyword 'sorted', used to include a sorted list of songs This plugin provides keyword 'sort', used to include a sorted list of songs
to a songbook. to a songbook.
""" """
@ -8,13 +8,13 @@ import logging
import unidecode import unidecode
from patacrep import files from patacrep import files
from patacrep.content import ContentError, EmptyContentList from patacrep.content import ContentError
from patacrep.content import process_content from patacrep.content import process_content, validate_parser_argument
from patacrep.content.song import OnlySongsError from patacrep.content.song import OnlySongsError
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
DEFAULT_SORT = ['by', 'album', '@title'] DEFAULT_SORT = ['by', 'album', 'title']
def normalize_string(string): def normalize_string(string):
"""Return a normalized string. """Return a normalized string.
@ -45,9 +45,9 @@ def key_generator(sort):
song = songrenderer.song song = songrenderer.song
songkey = [] songkey = []
for key in sort: for key in sort:
if key == "@title": if key == "title":
field = song.unprefixed_titles field = song.unprefixed_titles
elif key == "@path": elif key == "path":
field = song.fullpath field = song.fullpath
elif key == "by": elif key == "by":
field = song.authors field = song.authors
@ -67,29 +67,44 @@ def key_generator(sort):
return ordered_song_keys return ordered_song_keys
#pylint: disable=unused-argument #pylint: disable=unused-argument
def parse(keyword, config, argument, contentlist): @validate_parser_argument("""
"""Return a sorted list of songs contained in 'contentlist'. type: //any
of:
- type: //nil
- type: //rec
optional:
key:
type: //any
of:
- //str
- type: //arr
contents: //str
content: //any
""")
def parse(keyword, config, argument):
"""Return a sorted list of songs.
Arguments: Arguments:
- keyword: the string 'sorted'; - keyword: the string 'sort';
- config: the current songbook configuration dictionary; - config: the current songbook configuration dictionary;
- argument: the list of the fields used to sort songs, as strings - argument: a dict of:
separated by commas (e.g. "by, album, @title"); key: the list of the fields used to sort songs (e.g. "by", "album", "title")
- contentlist: the list of content to be sorted. If this content content: content to be sorted. If this content
contain something else than a song, an exception is raised. contain something else than a song, an exception is raised.
""" """
if argument: if argument is None:
sort = [key.strip() for key in argument.split(",")] argument = {}
else: sort = argument.get('key', DEFAULT_SORT)
sort = DEFAULT_SORT if isinstance(sort, str):
sort = [sort]
try: try:
songlist = process_content(contentlist, config) songlist = process_content(argument.get('content'), config)
except OnlySongsError as error: except OnlySongsError as error:
return EmptyContentList(errors=[ContentError(keyword, ( raise ContentError(keyword, (
"Content list of this keyword can be only songs (or content " "Content list of this keyword can be only songs (or content "
"that result into songs), and the following are not:" + "that result into songs), and the following are not:" +
str(error.not_songs) str(error.not_songs)
))]) ))
return sorted(songlist, key=key_generator(sort)) return sorted(songlist, key=key_generator(sort))
CONTENT_PLUGINS = {'sorted': parse} CONTENT_PLUGINS = {'sort': parse}

28
patacrep/content/tex.py

@ -5,7 +5,7 @@ import logging
import os import os
from patacrep import files, errors from patacrep import files, errors
from patacrep.content import ContentItem, ContentList, ContentError from patacrep.content import ContentItem, ContentList, ContentError, validate_parser_argument
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -22,26 +22,33 @@ class LaTeX(ContentItem):
))) )))
#pylint: disable=unused-argument #pylint: disable=unused-argument
def parse(keyword, argument, contentlist, config): @validate_parser_argument("""
"""Parse the contentlist. type: //any
of:
- type: //arr
contents: //str
- type: //str
""")
def parse(keyword, argument, config):
"""Parse the tex files.
Arguments: Arguments:
- keyword: unused; - keyword: unused;
- argument: unused; - argument:
- contentlist: a list of name of tex files; a list of tex files to include
or a string of the tex file to include;
- config: configuration dictionary of the current songbook. - config: configuration dictionary of the current songbook.
""" """
if not contentlist: if isinstance(argument, str):
LOGGER.warning( argument = [argument]
"Useless 'tex' content: list of files to include is empty."
)
filelist = ContentList() filelist = ContentList()
basefolders = itertools.chain( basefolders = itertools.chain(
(path.fullpath for path in config['_songdir']), (path.fullpath for path in config['_songdir']),
files.iter_datadirs(config['_datadir']), files.iter_datadirs(config['_datadir']),
files.iter_datadirs(config['_datadir'], 'latex'), files.iter_datadirs(config['_datadir'], 'latex'),
) )
for filename in contentlist: for filename in argument:
checked_file = None checked_file = None
for path in basefolders: for path in basefolders:
if os.path.exists(os.path.join(path, filename)): if os.path.exists(os.path.join(path, filename)):
@ -62,5 +69,4 @@ def parse(keyword, argument, contentlist, config):
return filelist return filelist
CONTENT_PLUGINS = {'tex': parse} CONTENT_PLUGINS = {'tex': parse}

2
patacrep/data/templates/patacrep.tex

@ -63,7 +63,7 @@ default:
(* block songbookpackages *) (* block songbookpackages *)
\usepackage[ \usepackage[
(* for option in bookoptions *)((option)), (* for option in _bookoptions *)((option)),
(* endfor *) (* endfor *)
]{crepbook} ]{crepbook}
(* endblock *) (* endblock *)

3
patacrep/data/templates/songbook_model.yml

@ -5,6 +5,7 @@ schema:
template: //any template: //any
required: required:
_cache: //bool _cache: //bool
_filepath: //str
_datadir: _datadir:
type: //arr type: //arr
contents: //str contents: //str
@ -82,7 +83,7 @@ default:
lang: en lang: en
encoding: utf-8 encoding: utf-8
pictures: yes pictures: yes
template: default.tex template: patacrep.tex
onesongperpage: no onesongperpage: no
chords: # Options relatives aux accords chords: # Options relatives aux accords

2
patacrep/data/templates/songs.tex

@ -22,7 +22,7 @@
(* block songbookpackages *) (* block songbookpackages *)
\usepackage[ \usepackage[
(* for option in bookoptions *)((option)), (* for option in _bookoptions *)((option)),
(* endfor *) (* endfor *)
]{patacrep} ]{patacrep}
(* endblock *) (* endblock *)

12
patacrep/errors.py

@ -7,15 +7,19 @@ class SongbookError(Exception):
""" """
pass pass
class SBFileError(SongbookError): class SchemaError(SongbookError):
"""Error during songbook file decoding""" """Error on the songbook schema"""
def __init__(self, message=None): def __init__(self, message='', rx_exception=None):
super().__init__() super().__init__()
self.message = message self.message = message
self.rx_exception = rx_exception
def __str__(self): def __str__(self):
return self.message if self.rx_exception:
return self.message + str(self.rx_exception)
else:
return self.message
class TemplateError(SongbookError): class TemplateError(SongbookError):
"""Error during template generation""" """Error during template generation"""

10
patacrep/songbook/__main__.py

@ -153,6 +153,9 @@ def main():
default_songbook.update(user_songbook) default_songbook.update(user_songbook)
songbook = dict(default_songbook) songbook = dict(default_songbook)
songbook['_filepath'] = os.path.abspath(songbook_path)
sbdir = os.path.dirname(songbook['_filepath'])
# Gathering datadirs # Gathering datadirs
datadirs = [] datadirs = []
if options.datadir: if options.datadir:
@ -162,16 +165,13 @@ def main():
if isinstance(songbook['book']['datadir'], str): if isinstance(songbook['book']['datadir'], str):
songbook['book']['datadir'] = [songbook['book']['datadir']] songbook['book']['datadir'] = [songbook['book']['datadir']]
datadirs += [ datadirs += [
os.path.join( os.path.join(sbdir, path)
os.path.dirname(os.path.abspath(songbook_path)),
path
)
for path in songbook['book']['datadir'] for path in songbook['book']['datadir']
] ]
del songbook['book']['datadir'] del songbook['book']['datadir']
# Default value # Default value
datadirs.append(os.path.dirname(os.path.abspath(songbook_path))) datadirs.append(sbdir)
songbook['_datadir'] = datadirs songbook['_datadir'] = datadirs
songbook['_cache'] = options.cache[0] songbook['_cache'] = options.cache[0]

105
patacrep/templates.py

@ -8,7 +8,7 @@ import yaml
from jinja2 import Environment, FileSystemLoader, ChoiceLoader, \ from jinja2 import Environment, FileSystemLoader, ChoiceLoader, \
TemplateNotFound, nodes TemplateNotFound, nodes
from jinja2.ext import Extension from jinja2.ext import Extension
from jinja2.meta import find_referenced_templates as find_templates from jinja2.meta import find_referenced_templates
from patacrep import errors, files, utils from patacrep import errors, files, utils
from patacrep.latex import lang2babel, UnknownLanguage from patacrep.latex import lang2babel, UnknownLanguage
@ -162,82 +162,54 @@ class TexBookRenderer(Renderer):
) )
def get_all_variables(self, user_config): def get_all_variables(self, user_config):
''' '''Validate template variables (and set defaults when needed)
Validate template variables (and set defaults when needed)
Will raise `SchemaError` if any data does not respect the schema
''' '''
data = self.get_template_variables(self.template) data = self.get_template_variables(self.template)
variables = dict() variables = dict()
for name, param in data.items(): for templatename, param in data.items():
template_config = user_config.get(name, {}) template_config = user_config.get(templatename, {})
variables[name] = self._get_variables(param, template_config) try:
variables[templatename] = self._get_variables(param, template_config)
except errors.SchemaError as exception:
exception.message += "'template' > '{}' > ".format(templatename)
raise exception
return variables return variables
@staticmethod @staticmethod
def _get_variables(parameter, user_config): def _get_variables(parameter, user_config):
'''Get the default value for the parameter, according to the language. '''Get the default value for the parameter, according to the language.
'''
schema = parameter.get('schema', {})
Will raise `SchemaError` if the data does not respect the schema
'''
data = utils.DictOfDict(parameter.get('default', {})) data = utils.DictOfDict(parameter.get('default', {}))
data.update(user_config) data.update(user_config)
schema = parameter.get('schema', {})
utils.validate_yaml_schema(data, schema) utils.validate_yaml_schema(data, schema)
return data return data
def get_template_variables(self, template, skip=None): def get_template_variables(self, basetemplate):
"""Parse the template to extract the variables as a dictionary. """Parse the template to extract the variables as a dictionary.
If the template includes or extends other templates, load them as well. If the template includes or extends other templates, load them as well.
Arguments: Arguments:
- template: the name of the template, as a string. - basetemplate: the name of the template, as a string.
- skip: a list of templates (as strings) to skip: if they are included
in 'template' (or one of its subtemplates), it is not parsed. in 'template' (or one of its subtemplates), it is not parsed.
""" """
if not skip:
skip = []
variables = {} variables = {}
(current, templates) = self.parse_template(template) for templatename, template in self._iter_template_content(basetemplate):
if current: match = re.findall(_VARIABLE_REGEXP, template)
variables[template.name] = current if not match:
for subtemplate in templates:
if subtemplate in skip:
continue continue
subtemplate = self.jinjaenv.get_template(subtemplate) if templatename not in variables:
variables.update( variables[templatename] = {}
self.get_template_variables( for variables_string in match:
subtemplate,
skip + templates
)
)
return variables
def parse_template(self, template):
"""Return (variables, templates).
Argument:
- template: name of the template to parse.
Return values:
- variables: a dictionary of variables contained in 'template', NOT
recursively (included templates are not parsed).
- templates: list of included temlates, NOT recursively.
"""
subvariables = {}
templatename = self.jinjaenv.get_template(template).filename
with patacrep.encoding.open_read(
templatename,
encoding=self.encoding
) as template_file:
content = template_file.read()
subtemplates = list(find_templates(self.jinjaenv.parse(content)))
match = re.findall(_VARIABLE_REGEXP, content)
if match:
for var in match:
try: try:
subvariables.update(yaml.load(var)) variables[templatename].update(yaml.load(variables_string))
except ValueError as exception: except ValueError as exception:
raise errors.TemplateError( raise errors.TemplateError(
exception, exception,
@ -246,12 +218,29 @@ class TexBookRenderer(Renderer):
"{filename}. The yaml string was:" "{filename}. The yaml string was:"
"\n'''\n{yamlstring}\n'''" "\n'''\n{yamlstring}\n'''"
).format( ).format(
filename=templatename, filename=template.filename,
yamlstring=var, yamlstring=variables_string,
) )
) )
return variables
return (subvariables, subtemplates) def _iter_template_content(self, templatename, *, skip=None):
"""Iterate over template (and subtemplate) content."""
if skip is None:
skip = []
template = self.jinjaenv.get_template(templatename)
with patacrep.encoding.open_read(
template.filename,
encoding=self.encoding
) as contentfile:
content = contentfile.read()
for subtemplatename in find_referenced_templates(self.jinjaenv.parse(content)):
if subtemplatename not in skip:
yield from self._iter_template_content(
subtemplatename,
skip=skip + [templatename],
)
yield template.name, content
def render_tex(self, output, context): def render_tex(self, output, context):
'''Render a template into a .tex file '''Render a template into a .tex file
@ -264,7 +253,7 @@ class TexBookRenderer(Renderer):
output.write(self.template.render(context)) output.write(self.template.render(context))
def transform_options(config, equivalents): def _transform_options(config, equivalents):
""" """
Get the equivalent name of the checked options Get the equivalent name of the checked options
""" """
@ -285,14 +274,14 @@ def iter_bookoptions(config):
'pictures': 'pictures', 'pictures': 'pictures',
'onesongperpage': 'onesongperpage', 'onesongperpage': 'onesongperpage',
} }
yield from transform_options(config['book'], book_equivalents) yield from _transform_options(config['book'], book_equivalents)
chords_equivalents = { chords_equivalents = {
'lilypond': 'lilypond', 'lilypond': 'lilypond',
'tablatures': 'tabs', 'tablatures': 'tabs',
'repeatchords': 'repeatchords', 'repeatchords': 'repeatchords',
} }
yield from transform_options(config['chords'], chords_equivalents) yield from _transform_options(config['chords'], chords_equivalents)
if config['chords']['show']: if config['chords']['show']:
if config['chords']['diagramreminder'] == "important": if config['chords']['diagramreminder'] == "important":

10
patacrep/utils.py

@ -81,16 +81,14 @@ def yesno(string):
def validate_yaml_schema(data, schema): def validate_yaml_schema(data, schema):
"""Check that the data respects the schema """Check that the data respects the schema
Will raise `SBFileError` if the schema is not respected. Will raise `SchemaError` if the schema is not respected.
""" """
rx_checker = Rx.Factory({"register_core_types": True}) schema = Rx.make_schema(schema)
schema = rx_checker.make_schema(schema)
if not isinstance(data, dict): if isinstance(data, DictOfDict):
data = dict(data) data = dict(data)
try: try:
schema.validate(data) schema.validate(data)
except Rx.SchemaMismatch as exception: except Rx.SchemaMismatch as exception:
msg = 'Could not parse songbook file:\n' + str(exception) raise errors.SchemaError(rx_exception=exception)
raise errors.SBFileError(msg)

2
test/test_content/cwd.control

@ -1 +1 @@
["subdir/chordpro.csg"] - subdir/chordpro.csg

4
test/test_content/cwd.source

@ -1 +1,3 @@
[["cwd(subdir)"]] - cwd:
path: subdir
content:

3
test/test_content/cwd_list.control

@ -1 +1,2 @@
["subdir/chordpro.csg", "exsong.sg"] - subdir/chordpro.csg
- exsong.sg

12
test/test_content/cwd_list.source

@ -1 +1,11 @@
[["cwd(subdir)", "exsong.sg", "intersong.is", "jsonlist.json", "texfile.tex", "texsong.tsg", "chordpro.csg", "subdir/chordpro.csg"], "exsong.sg"] - cwd:
path: subdir
content:
- "exsong.sg"
- "intersong.is"
- "jsonlist.json"
- "texfile.tex"
- "texsong.tsg"
- "chordpro.csg"
- "subdir/chordpro.csg"
- "exsong.sg"

6
test/test_content/datadir/songs/custom_list.yaml

@ -0,0 +1,6 @@
- sort:
key: "title"
content:
- exsong.sg
- chordpro.csg
- subdir/chordpro.csg

5
test/test_content/datadir_sort/path1_title1_author1.csg

@ -0,0 +1,5 @@
{title: Title1}
{artist: Author1}
Foo bar

5
test/test_content/datadir_sort/path1_title1_author2.csg

@ -0,0 +1,5 @@
{title: Title1}
{artist: Author2}
Foo bar

5
test/test_content/datadir_sort/path1_title2_author1.csg

@ -0,0 +1,5 @@
{title: Title2}
{artist: Author1}
Foo bar

5
test/test_content/datadir_sort/path1_title2_author2.csg

@ -0,0 +1,5 @@
{title: Title2}
{artist: Author2}
Foo bar

5
test/test_content/datadir_sort/path2_title1_author1.csg

@ -0,0 +1,5 @@
{title: Title1}
{artist: Author1}
Foo bar

5
test/test_content/datadir_sort/path2_title1_author2.csg

@ -0,0 +1,5 @@
{title: Title1}
{artist: Author2}
Foo bar

5
test/test_content/datadir_sort/path2_title2_author1.csg

@ -0,0 +1,5 @@
{title: Title2}
{artist: Author1}
Foo bar

5
test/test_content/datadir_sort/path2_title2_author2.csg

@ -0,0 +1,5 @@
{title: Title2}
{artist: Author2}
Foo bar

2
test/test_content/glob.control

@ -1 +1 @@
["chordpro.csg"] - chordpro.csg

2
test/test_content/glob.source

@ -1 +1 @@
["*.csg"] - "*.csg"

7
test/test_content/include.control

@ -1 +1,6 @@
["exsong.sg", "chordpro.csg", "subdir/chordpro.csg"] - exsong.sg
- chordpro.csg
- subdir/chordpro.csg
- chordpro.csg
- subdir/chordpro.csg
- exsong.sg

4
test/test_content/include.source

@ -1 +1,3 @@
[["include" , "custom_list.json"]] - include:
- custom_list.json
- include: custom_list.yaml

1
test/test_content/invalid.control

@ -0,0 +1 @@
[]

4
test/test_content/invalid.source

@ -0,0 +1,4 @@
- ["directly", "a", "list"]
- invalid_keyword: Test
- section:
short: Missing name

12
test/test_content/sections.control

@ -1 +1,11 @@
["section:Traditional", "exsong.sg", "section:Example", "texsong.tsg", "chordpro.csg", "exsong.sg"] - section{First Section!}
- section{Named section}
- section[section_short_name]{Section with short name}
- section*{Section* with short name}
- part{part section test}
- chapter{chapter section test}
- section{section section test}
- subsection{subsection section test}
- subsubsection{subsubsection section test}
- paragraph{paragraph section test}
- subparagraph{subparagraph section test}

22
test/test_content/sections.source

@ -1,6 +1,16 @@
[["section", "Traditional"], - section: First Section!
"exsong.sg", - section:
["section", "Example"], name: Named section
"texsong.tsg", - section:
"chordpro.csg", name: Section with short name
"exsong.sg"] short: section_short_name
- section*:
name: Section* with short name
short: section_star_short_name
- part: part section test
- chapter: chapter section test
- section: section section test
- subsection: subsection section test
- subsubsection: subsubsection section test
- paragraph: paragraph section test
- subparagraph: subparagraph section test

1
test/test_content/sections_short.control

@ -1 +0,0 @@
["section:(tradi)Traditional", "exsong.sg", "section*:Example", "texsong.tsg", "chordpro.csg", "exsong.sg"]

6
test/test_content/sections_short.source

@ -1,6 +0,0 @@
[["section", "Traditional", "tradi"],
"exsong.sg",
["section*", "Example"],
"texsong.tsg",
"chordpro.csg",
"exsong.sg"]

5
test/test_content/songs.control

@ -1 +1,4 @@
["exsong.sg", "texsong.tsg", "chordpro.csg", "subdir/chordpro.csg"] - exsong.sg
- texsong.tsg
- chordpro.csg
- subdir/chordpro.csg

9
test/test_content/songs.source

@ -1 +1,8 @@
["exsong.sg", "intersong.is", "jsonlist.json", "texfile.tex", "texsong.tsg", "chordpro.csg", "subdir/chordpro.csg"] - exsong.sg
- intersong.is
- jsonlist.json
- song:
- texfile.tex
- texsong.tsg
- song: chordpro.csg
- subdir/chordpro.csg

7
test/test_content/songsection.control

@ -1 +1,6 @@
["songsection:Traditional", "exsong.sg", "songchapter:English", "texsong.tsg", "chordpro.csg", "exsong.sg"] - songsection{Traditional}
- exsong.sg
- songchapter{English}
- texsong.tsg
- chordpro.csg
- exsong.sg

12
test/test_content/songsection.source

@ -1,6 +1,6 @@
[["songsection", "Traditional"], - songsection: Traditional
"exsong.sg", - "exsong.sg"
["songchapter", "English"], - songchapter: English
"texsong.tsg", - "texsong.tsg"
"chordpro.csg", - "chordpro.csg"
"exsong.sg"] - "exsong.sg"

27
test/test_content/sort.control

@ -0,0 +1,27 @@
- section{Title}
- "@TEST_FOLDER@/datadir_sort/path1_title1_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title1_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title1_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title1_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title2_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title2_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title2_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title2_author2.csg"
- section{Author, Title}
- "@TEST_FOLDER@/datadir_sort/path1_title1_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title1_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title2_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title2_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title1_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title1_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title2_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title2_author2.csg"
- section{Path, Title}
- "@TEST_FOLDER@/datadir_sort/path1_title1_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title1_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title2_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path1_title2_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title1_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title1_author2.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title2_author1.csg"
- "@TEST_FOLDER@/datadir_sort/path2_title2_author2.csg"

15
test/test_content/sort.source

@ -0,0 +1,15 @@
- cwd:
path: "datadir_sort"
content:
- section:
name: "Title"
- sort:
key: title
- section:
name: "Author, Title"
- sort:
key: [by, title]
- section:
name: "Path, Title"
- sort:
key: [path, title]

1
test/test_content/sorted.control

@ -1 +0,0 @@
["chordpro.csg", "exsong.sg", "subdir/chordpro.csg", "texsong.tsg"]

1
test/test_content/sorted.source

@ -1 +0,0 @@
[["sorted(fullpath)"]]

24
test/test_content/test_content.py

@ -5,7 +5,9 @@
import glob import glob
import os import os
import unittest import unittest
import json import yaml
from pkg_resources import resource_filename
from patacrep.songs import DataSubpath from patacrep.songs import DataSubpath
from patacrep import content, files from patacrep import content, files
@ -18,7 +20,7 @@ from .. import dynamic # pylint: disable=unused-import
class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest): class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest):
"""Test of the content plugins. """Test of the content plugins.
For any given `foo.source`, it parses the content as a json "content" For any given `foo.source`, it parses the content as a yaml "content"
argument of a .sb file. argument of a .sb file.
It controls that the generated file list is equal to the one in `foo.control`. It controls that the generated file list is equal to the one in `foo.control`.
""" """
@ -51,17 +53,22 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest):
"""Test that `base.source` produces the correct file list""" """Test that `base.source` produces the correct file list"""
sourcename = "{}.source".format(base) sourcename = "{}.source".format(base)
with open(sourcename, mode="r", encoding="utf8") as sourcefile: with open(sourcename, mode="r", encoding="utf8") as sourcefile:
sbcontent = json.load(sourcefile) sbcontent = yaml.load(sourcefile)
config = cls.config.copy()
config['_filepath'] = base
with logging_reduced('patacrep.content.song'): with logging_reduced('patacrep.content.song'):
expandedlist = content.process_content(sbcontent, cls.config.copy()) expandedlist = content.process_content(sbcontent, config)
sourcelist = [cls._clean_path(elem) for elem in expandedlist] sourcelist = [cls._clean_path(elem) for elem in expandedlist]
controlname = "{}.control".format(base) controlname = "{}.control".format(base)
if not os.path.exists(controlname): if not os.path.exists(controlname):
raise Exception("Missing control:" + str(sourcelist).replace("'", '"')) raise Exception("Missing control:" + str(sourcelist).replace("'", '"'))
with open(controlname, mode="r", encoding="utf8") as controlfile: with open(controlname, mode="r", encoding="utf8") as controlfile:
controllist = json.load(controlfile) controllist = [
elem.replace("@TEST_FOLDER@", files.path2posix(resource_filename(__name__, "")))
for elem in yaml.load(controlfile)
]
self.assertEqual(controllist, sourcelist) self.assertEqual(controllist, sourcelist)
@ -78,13 +85,10 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest):
return files.path2posix(files.relpath(elem.song.fullpath, songpath)) return files.path2posix(files.relpath(elem.song.fullpath, songpath))
elif isinstance(elem, section.Section): elif isinstance(elem, section.Section):
if elem.short is None: return elem.render(None)[1:]
return "{}:{}".format(elem.keyword, elem.name)
else:
return "{}:({}){}".format(elem.keyword, elem.short, elem.name)
elif isinstance(elem, songsection.SongSection): elif isinstance(elem, songsection.SongSection):
return "{}:{}".format(elem.keyword, elem.name) return elem.render(None)[1:]
elif isinstance(elem, tex.LaTeX): elif isinstance(elem, tex.LaTeX):
return files.path2posix(elem.filename) return files.path2posix(elem.filename)

3
test/test_content/tex.control

@ -1 +1,2 @@
["test/test_content/datadir/songs/texfile.tex"] - test/test_content/datadir/songs/texfile.tex
- test/test_content/datadir/songs/texfile.tex

5
test/test_content/tex.source

@ -1 +1,4 @@
[["tex", "texfile.tex", "chordpro.csg"]] - tex:
- texfile.tex
- chordpro.csg
- tex: texfile.tex

27
test/test_songbook/content.sb

@ -2,17 +2,24 @@ book:
pictures: yes pictures: yes
datadir: content_datadir datadir: content_datadir
lang: en lang: en
template: default.tex
chords: chords:
repeatchords: no repeatchords: no
diagramreminder: all diagramreminder: all
content: [ content:
["section", "Test of section"], - section: Test of section
["sorted"], - sort:
["songsection", "Test of song section"], - songsection: Test of song section
["cwd(content_datadir/content)", - cwd:
"song.csg", "song.tsg", # relative to sb file
["tex", "foo.tex"] path: content_datadir/content
], content:
["include", "include.sbc"] - "song.csg"
] - "song.tsg"
- cwd:
# relative to datadir
path: ../content
content:
- tex: foo.tex
- include: include.sbc

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

@ -1 +1 @@
[["section", "This is an included section"]] [{"section": "This is an included section"}]

1
test/test_songbook/datadir.sb

@ -4,3 +4,4 @@ book:
- datadir_datadir - datadir_datadir
- datadir_datadir2 - datadir_datadir2
lang: en lang: en
template: default.tex

1
test/test_songbook/languages.sb

@ -1,3 +1,4 @@
book: book:
template: default.tex
datadir: datadir:
- languages_datadir - languages_datadir

1
test/test_songbook/syntax.sb

@ -1,4 +1,5 @@
book: book:
template: default.tex
datadir: datadir:
- syntax_datadir - syntax_datadir
lang: en lang: en

1
test/test_songbook/unicode.sb

@ -1,4 +1,5 @@
book: book:
template: default.tex
datadir: datadir:
- unicode_datadir - unicode_datadir
lang: en lang: en

4
texlive_packages.txt

@ -1,11 +1,7 @@
babel-english
babel-esperanto babel-esperanto
babel-french
babel-german
babel-italian babel-italian
babel-latin babel-latin
babel-portuges babel-portuges
babel-spanish
ctablestack ctablestack
etoolbox etoolbox
fancybox fancybox

Loading…
Cancel
Save