mirror of https://github.com/patacrep/patacrep.git
Engine for LaTeX songbooks
http://www.patacrep.com
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
276 lines
9.3 KiB
276 lines
9.3 KiB
"""Song management."""
|
|
|
|
import errno
|
|
import hashlib
|
|
import jinja2
|
|
import logging
|
|
import os
|
|
import pickle
|
|
import re
|
|
|
|
from patacrep.authors import process_listauthors
|
|
from patacrep import files, encoding
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
def cached_name(datadir, filename):
|
|
"""Return the filename of the cache version of the file."""
|
|
fullpath = os.path.abspath(os.path.join(datadir, '.cache', filename))
|
|
directory = os.path.dirname(fullpath)
|
|
try:
|
|
os.makedirs(directory)
|
|
except OSError as error:
|
|
if error.errno == errno.EEXIST and os.path.isdir(directory):
|
|
pass
|
|
else:
|
|
raise
|
|
return fullpath
|
|
|
|
class DataSubpath(object):
|
|
"""A path divided in two path: a datadir, and its subpath.
|
|
|
|
- This object can represent either a file or directory.
|
|
- If the datadir part is the empty string, it means that the represented
|
|
path does not belong to a datadir.
|
|
"""
|
|
|
|
def __init__(self, datadir, subpath):
|
|
if os.path.isabs(subpath):
|
|
self.datadir = ""
|
|
else:
|
|
self.datadir = datadir
|
|
self.subpath = subpath
|
|
|
|
def __str__(self):
|
|
return os.path.join(self.datadir, self.subpath)
|
|
|
|
@property
|
|
def fullpath(self):
|
|
"""Return the full path represented by self."""
|
|
return os.path.join(self.datadir, self.subpath)
|
|
|
|
def clone(self):
|
|
"""Return a cloned object."""
|
|
return DataSubpath(self.datadir, self.subpath)
|
|
|
|
def join(self, path):
|
|
"""Join "path" argument to self path.
|
|
|
|
Return self for commodity.
|
|
"""
|
|
self.subpath = os.path.join(self.subpath, path)
|
|
return self
|
|
|
|
# pylint: disable=too-many-instance-attributes
|
|
class Song:
|
|
"""Song (or song metadata)
|
|
|
|
This class represents a song, bound to a file.
|
|
|
|
- It can parse the file given in arguments.
|
|
- It can render the song as some code (LaTeX, chordpro, depending on subclasses implemetation).
|
|
- Its content is cached, so that if the file has not been changed, the
|
|
file is not parsed again.
|
|
|
|
This class is inherited by classes implementing song management for
|
|
several file formats. Those subclasses must implement:
|
|
- `parse()` to parse the file;
|
|
- `render()` to render the song as code.
|
|
"""
|
|
|
|
# Version format of cached song. Increment this number if we update
|
|
# information stored in cache.
|
|
CACHE_VERSION = 2
|
|
|
|
# List of attributes to cache
|
|
cached_attributes = [
|
|
"titles",
|
|
"unprefixed_titles",
|
|
"cached",
|
|
"data",
|
|
"subpath",
|
|
"lang",
|
|
"authors",
|
|
"_filehash",
|
|
"_version",
|
|
]
|
|
|
|
def __init__(self, subpath, config, *, datadir=None):
|
|
if datadir is None:
|
|
self.datadir = ""
|
|
else:
|
|
self.datadir = datadir
|
|
self.fullpath = os.path.join(self.datadir, subpath)
|
|
self.encoding = config["encoding"]
|
|
self.config = config
|
|
|
|
if datadir:
|
|
# Only songs in datadirs are cached
|
|
self._filehash = hashlib.md5(
|
|
open(self.fullpath, 'rb').read()
|
|
).hexdigest()
|
|
if os.path.exists(cached_name(datadir, subpath)):
|
|
try:
|
|
cached = pickle.load(open(
|
|
cached_name(datadir, subpath),
|
|
'rb',
|
|
))
|
|
if (
|
|
cached['_filehash'] == self._filehash
|
|
and cached['_version'] == self.CACHE_VERSION
|
|
):
|
|
for attribute in self.cached_attributes:
|
|
setattr(self, attribute, cached[attribute])
|
|
return
|
|
except: # pylint: disable=bare-except
|
|
LOGGER.warning("Could not use cached version of {}.".format(
|
|
self.fullpath
|
|
))
|
|
|
|
# Data extraction from the latex song
|
|
self.titles = []
|
|
self.data = {}
|
|
self.cached = None
|
|
self.lang = None
|
|
self._parse(config)
|
|
|
|
# Post processing of data
|
|
self.subpath = subpath
|
|
self.unprefixed_titles = [
|
|
unprefixed_title(
|
|
title,
|
|
config['titleprefixwords']
|
|
)
|
|
for title
|
|
in self.titles
|
|
]
|
|
self.authors = process_listauthors(
|
|
self.authors,
|
|
**config.get("_compiled_authwords", {})
|
|
)
|
|
|
|
# Cache management
|
|
self._version = self.CACHE_VERSION
|
|
self._write_cache()
|
|
|
|
def _write_cache(self):
|
|
"""If relevant, write a dumbed down version of self to the cache."""
|
|
if self.datadir:
|
|
cached = {}
|
|
for attribute in self.cached_attributes:
|
|
cached[attribute] = getattr(self, attribute)
|
|
pickle.dump(
|
|
cached,
|
|
open(cached_name(self.datadir, self.subpath), 'wb'),
|
|
protocol=-1
|
|
)
|
|
|
|
def __repr__(self):
|
|
return repr((self.titles, self.data, self.fullpath))
|
|
|
|
def render(self, output=None, *args, **kwargs):
|
|
"""Return the code rendering this song.
|
|
|
|
Arguments:
|
|
- output: Name of the output file, or `None` if irrelevant.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def _parse(self, config): # pylint: disable=no-self-use
|
|
"""Parse song.
|
|
|
|
It set the following attributes:
|
|
|
|
- titles: the list of (raw) titles. This list will be processed to
|
|
remove prefixes.
|
|
- lang: the main language of the song, as language code..
|
|
- authors: the list of (raw) authors. This list will be processed to
|
|
'clean' it (see function :func:`patacrep.authors.processauthors`).
|
|
- data: song metadata. Used (among others) to sort the songs.
|
|
- cached: additional data that will be cached. Thus, data stored in
|
|
this attribute must be picklable.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def get_datadirs(self, subdir=None):
|
|
"""Return an iterator of existing datadirs (with eventually a subdir)
|
|
"""
|
|
for directory in self.config['datadir']:
|
|
fullpath = os.path.join(directory, subdir)
|
|
if os.path.isdir(fullpath):
|
|
yield fullpath
|
|
|
|
def search_datadir_file(self, filename, extensions=None, directories=None):
|
|
"""Search for a file name.
|
|
|
|
:param str filename: The name, as provided in the chordpro file (with or without extension).
|
|
:param list extensions: Possible extensions (with '.'). Default is no extension.
|
|
:param iterator directories: Other directories where to search for the file
|
|
The directory where the Song file is stored is added to the list.
|
|
:return: A tuple `(datadir, filename, extension)` if file has been
|
|
found. It is guaranteed that `os.path.join(datadir,
|
|
filename+extension)` is a (relative or absolute) valid path to an
|
|
existing filename.
|
|
* `datadir` is the datadir in which the file has been found. Can be
|
|
the empty string.
|
|
* `filename` is the filename, relative to the datadir.
|
|
* `extension` is the extension that is to be appended to the
|
|
filename to get the real filename. Can be the empty string.
|
|
|
|
Raise `FileNotFoundError` if nothing found.
|
|
|
|
This function can also be used as a preprocessor for a renderer: for
|
|
instance, it can compile a file, place it in a temporary folder, and
|
|
return the path to the compiled file.
|
|
"""
|
|
if extensions is None:
|
|
extensions = ['']
|
|
if directories is None:
|
|
directories = self.config['datadir']
|
|
|
|
songdir = os.path.dirname(self.fullpath)
|
|
for extension in extensions:
|
|
if os.path.isfile(os.path.join(songdir, filename + extension)):
|
|
return "", os.path.join(songdir, filename), extension
|
|
|
|
for directory in directories:
|
|
for extension in extensions:
|
|
if os.path.isfile(os.path.join(directory, filename + extension)):
|
|
return directory, filename, extension
|
|
|
|
raise FileNotFoundError(filename)
|
|
|
|
def search_file(self, filename, extensions=None, *, datadirs=None):
|
|
"""Return the path to a file present in a datadir.
|
|
|
|
Implementation is specific to each renderer, as:
|
|
- some renderers can preprocess files;
|
|
- some renderers can return the absolute path, other can return something else;
|
|
- etc.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def search_image(self, filename):
|
|
"""Search for an image file"""
|
|
return self.search_file(
|
|
filename,
|
|
['', '.jpg', '.png'],
|
|
datadirs=self.get_datadirs('img'),
|
|
)
|
|
|
|
def search_partition(self, filename):
|
|
"""Search for a lilypond file"""
|
|
return self.search_file(
|
|
filename,
|
|
['', '.ly'],
|
|
datadirs=self.get_datadirs('scores'),
|
|
)
|
|
|
|
def unprefixed_title(title, prefixes):
|
|
"""Remove the first prefix of the list in the beginning of title (if any).
|
|
"""
|
|
for prefix in prefixes:
|
|
match = re.compile(r"^(%s)\b\s*(.*)$" % prefix, re.LOCALE).match(title)
|
|
if match:
|
|
return match.group(2)
|
|
return title
|
|
|