Source code for xl.formatter

# -*- coding: utf-8 -*-
# Copyright (C) 2010 Mathias Brodala
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
#
# The developers of the Exaile media player hereby grant permission
# for non-GPL compatible GStreamer and Exaile plugins to be used and
# distributed together with GStreamer and Exaile. This permission is
# above and beyond the permissions granted by the GPL license by which
# Exaile is covered. If you modify this code, you may extend this
# exception to your version of the code, but you are not obligated to
# do so. If you do not wish to do so, delete this exception statement
# from your version.

"""
Provides an extensible framework for processing and
preparation of data for display in various contexts.
"""

from datetime import date
from gi.repository import GLib
from gi.repository import GObject
import re
from string import Template, _TemplateMetaclass

from xl import common, providers, settings, trax
from xl.common import TimeSpan
from xl.nls import gettext as _, ngettext


class _ParameterTemplateMetaclass(_TemplateMetaclass):
    # Allows for $tag, ${tag}, ${tag:parameter} and ${tag:parameter=argument}
    pattern = r"""
    %(delim)s(?:
      (?P<escaped>%(delim)s) |     # Escape sequence of two delimiters
      (?P<named>%(id)s)      |     # Delimiter and a Python identifier
      {
        (?P<braced>%(id)s)         # Delimiter and a braced identifier
        (!?:
          (?P<parameters>
            %(id)s                 # First optional parameter indicated with ':'
            (!?=(!?%(arg)s)+?)?    # Optional argument indicated with '='
            (!?,\s*%(id)s          # Further optional parameters separated with ','
              (!?=(!?%(arg)s)+?)?  # Optional argument indicated with '='
            )*
          )
        )?
      }                      |
      (?P<invalid>)                # Other ill-formed delimiter expressions
    )
    """

    def __init__(cls, name, bases, dct):
        super(_ParameterTemplateMetaclass, cls).__init__(name, bases, dct)
        if 'pattern' in dct:
            pattern = cls.pattern
        else:
            pattern = _ParameterTemplateMetaclass.pattern % {
                'delim': re.escape(cls.delimiter),
                'id': cls.idpattern,
                'arg': cls.argpattern,
            }
        cls.pattern = re.compile(pattern, re.IGNORECASE | re.VERBOSE)


[docs]class ParameterTemplate(Template): """ An extended template class which additionally accepts parameters assigned to identifiers. This introduces another pattern group named "parameters" in addition to the groups created by :class:`string.Template` Examples: * ``${foo:parameter1}`` * ``${bar:parameter1, parameter2}`` * ``${qux:parameter1=argument1, parameter2}`` """ __metaclass__ = _ParameterTemplateMetaclass argpattern = r'[^,}=]|\,|\}|\=' def __init__(self, template): """ :param template: the template string """ Template.__init__(self, template) def safe_substitute(self, *args): """ Overridden to allow for parametrized identifiers """ if len(args) > 1: raise TypeError('Too many positional arguments') if not args: mapping = {} else: mapping = args[0] # Helper function for .sub() def convert(mo): named = mo.group('named') if named is not None: try: # We use this idiom instead of str() because the latter # will fail if val is a Unicode containing non-ASCII return '%s' % (mapping[named],) except KeyError: return self.delimiter + named braced = mo.group('braced') if braced is not None: parts = [braced] parameters = mo.group('parameters') if parameters is not None: parts += [parameters] try: return '%s' % (mapping[':'.join(parts)],) except KeyError: return self.delimiter + '{' + ':'.join(parts) + '}' if mo.group('escaped') is not None: return self.delimiter if mo.group('invalid') is not None: return self.delimiter raise ValueError('Unrecognized named group in pattern', self.pattern) return self.pattern.sub(convert, self.template)
class Formatter(GObject.GObject): """ A generic text formatter based on a format string By default the following parameters are provided to each identifier: * ``prefix``, ``suffix``: a string to put before or after the formatted string if that string is not empty * Whitespace will be not be touched and transferred as is * The characters ``,``, ``}`` and ``=`` need to be escaped like ``\,``, ``\}`` and ``\=`` respectively * ``pad``: desired length the formatted string should have, will be achieved using the ``padstring`` * ``padstring``: a string to use for padding, will be repeated as often as possible to achieve the desired length specified by ``pad`` * Example: ``${identifier:pad=4, padstring=XY}`` for *identifier* having the value *a* will become *XYXa* """ __gproperties__ = { 'format': ( GObject.TYPE_STRING, 'format string', 'String the formatting is based on', '', GObject.PARAM_READWRITE, ) } def __init__(self, format): """ :param format: the initial format, see the documentation of :class:`string.Template` for details :type format: string """ GObject.GObject.__init__(self) self._template = ParameterTemplate(format) self._substitutions = {} def do_get_property(self, property): """ Gets GObject properties """ if property.name == 'format': return self._template.template else: raise AttributeError('unknown property %s' % property.name) def do_set_property(self, property, value): """ Sets GObject properties """ if property.name == 'format': if value != self._template.template: self._template.template = value else: raise AttributeError('unknown property %s' % property.name) def extract(self): """ Retrieves the identifiers and their optional parameters Example of the returned dictionary:: extractions = { 'identifier1': ( 'identifier1', {}), 'identifier2:parameter': ( 'identifier2', {'parameter': True}), 'identifier3:parameter=argument': ( 'identifier3', {'parameter': 'argument'}) } :returns: the extractions :rtype: dict """ matches = self._template.pattern.finditer(self._template.template) extractions = {} # Extract list of identifiers and parameters from the format string for match in matches: groups = match.groupdict() # We only care about braced and named, not escaped and invalid identifier = groups['braced'] or groups['named'] if identifier is None: continue identifier_parts = [identifier] parameters = {} if groups['parameters'] is not None: # Split parameters on unescaped comma parameters = [ p.lstrip() for p in re.split(r'(?<!\\),', groups['parameters']) ] # Split arguments on unescaped equals sign parameters = [ (re.split(r'(?<!\\)=', p, 1) + [True])[:2] for p in parameters ] # Turn list of lists into a proper dictionary parameters = dict(parameters) # Remove now obsolete escapes for p in parameters: argument = parameters[p] if not isinstance(argument, bool): argument = argument.replace(r'\,', ',') argument = argument.replace(r'\}', '}') argument = argument.replace(r'\=', '=') parameters[p] = argument identifier_parts += [groups['parameters']] # Required to make multiple occurences of the same # identifier with different parameters work extractions[':'.join(identifier_parts)] = (identifier, parameters) return extractions def format(self, *args): """ Returns a string by formatting the passed data :param args: data to base the formatting on :returns: the formatted text :rtype: string """ extractions = self.extract() substitutions = {} for needle, (identifier, parameters) in extractions.iteritems(): substitute = None if needle in self._substitutions: substitute = self._substitutions[needle] elif identifier in self._substitutions: substitute = self._substitutions[identifier] if substitute is not None: prefix = parameters.pop('prefix', '') suffix = parameters.pop('suffix', '') pad = int(parameters.pop('pad', 0)) padstring = parameters.pop('padstring', '') if callable(substitute): substitute = substitute(*args, **parameters) if pad > 0 and padstring: # Decrease pad length by value length pad = max(0, pad - len(substitute)) # Retrieve the maximum multiplier for the pad string padcount = pad / len(padstring) + 1 # Generate pad string padstring = padcount * padstring # Clamp pad string padstring = padstring[0:pad] substitute = '%s%s' % (padstring, substitute) if substitute: substitute = '%s%s%s' % (prefix, substitute, suffix) substitutions[needle] = substitute return self._template.safe_substitute(substitutions) class ProgressTextFormatter(Formatter): """ A text formatter for progress indicators """ def __init__(self, format, player): Formatter.__init__(self, format) self._player = player def format(self, current_time=None, total_time=None): """ Returns a string suitable for progress indicators :param current_time: the current progress, taken from the current playback if not set :type current_time: float :param total_time: the total length of a track, taken from the current playback if not set :type total_time: float :returns: The formatted text :rtype: string """ total_remaining_time = 0 if current_time is None: current_time = self._player.get_time() if total_time is None: track = self._player.current if track is not None: total_time = track.get_tag_raw('__length') if total_time is None: total_time = remaining_time = 0 else: remaining_time = total_time - current_time playlist = self._player.queue.current_playlist if playlist and playlist.current_position >= 0: tracks = playlist[playlist.current_position :] total_remaining_time = sum(t.get_tag_raw('__length') or 0 for t in tracks) total_remaining_time -= current_time self._substitutions['current_time'] = LengthTagFormatter.format_value( current_time ) self._substitutions['remaining_time'] = LengthTagFormatter.format_value( remaining_time ) self._substitutions['total_time'] = LengthTagFormatter.format_value(total_time) self._substitutions['total_remaining_time'] = LengthTagFormatter.format_value( total_remaining_time ) return Formatter.format(self) class TrackFormatter(Formatter): """ A formatter for track data """ def format(self, track, markup_escape=False): """ Returns a string for places where track data is presented to the user :param track: a single track to take data from :type track: :class:`xl.trax.Track` :param markup_escape: whether to escape markup-like characters in tag values :type markup_escape: bool :returns: the formatted text :rtype: string """ if not isinstance(track, trax.Track): raise TypeError( 'First argument to format() needs ' 'to be of type xl.trax.Track' ) extractions = self.extract() self._substitutions = {} for identifier, (tag, parameters) in extractions.iteritems(): provider = providers.get_provider('tag-formatting', tag) if provider is None: substitute = track.get_tag_display(tag) else: substitute = provider.format(track, parameters) if markup_escape: substitute = GLib.markup_escape_text(substitute).decode('utf-8') self._substitutions[identifier] = substitute return Formatter.format(self)
[docs]class TagFormatter(object): """ A formatter provider for a tag of a track """ def __init__(self, name): """ :param name: the name of the tag :type name: string """ self.name = name
[docs] def format(self, track, parameters): """ Formats a raw tag value. Accepts optional parameters to manipulate the formatting process. :param track: the track to get the tag from :type track: :class:`xl.trax.Track` :param parameters: optionally passed parameters :type parameters: dictionary :returns: the formatted value :rtype: string """ pass
class NumberTagFormatter(TagFormatter): """ A generic formatter for numeric formatting Removes count values, e.g. "b" in "a/b" """ def __init__(self, name): """ :param name: the name of the tag :type name: string """ TagFormatter.__init__(self, name) def format(self, track, parameters): """ Formats a raw tag value :param track: the track to get the tag from :type track: :class:`xl.trax.Track` :param parameters: optionally passed parameters :type parameters: dictionary :returns: the formatted value :rtype: string """ value = track.get_tag_raw(self.name, join=True) if not value: return '' try: # n/n value, count = value.split('/') except ValueError: # n pass if not value: return '' try: value = int(value) except ValueError: return '' return '%d' % value
[docs]class TrackNumberTagFormatter(NumberTagFormatter): """ A formatter for the tracknumber of a track """ def __init__(self): TagFormatter.__init__(self, 'tracknumber')
providers.register('tag-formatting', TrackNumberTagFormatter())
[docs]class DiscNumberTagFormatter(NumberTagFormatter): """ A formatter for the discnumber of a track """ def __init__(self): TagFormatter.__init__(self, 'discnumber')
providers.register('tag-formatting', DiscNumberTagFormatter())
[docs]class ArtistTagFormatter(TagFormatter): """ A formatter for the artist of a track """ def __init__(self): TagFormatter.__init__(self, 'artist')
[docs] def format(self, track, parameters): """ Formats a raw tag value :param track: the track to get the tag from :type track: :class:`xl.trax.Track` :param parameters: optionally passed parameters Possible values are: * compilate: Allows for proper handling of compilations, either via albumartist tag, a fallback value, or simply all artists :returns: the formatted value :rtype: string """ compilate = parameters.get('compilate', False) value = track.get_tag_display(self.name, artist_compilations=compilate) return value
providers.register('tag-formatting', ArtistTagFormatter()) class TimeTagFormatter(TagFormatter): """ A formatter for a time period """ def format(self, track, parameters): """ Formats a raw tag value :param track: the track to get the tag from :type track: :class:`xl.trax.Track` :param parameters: Verbosity of the output, possible values for "format" are: * short: "1:02:42" * long: "1h, 2m, 42s" * verbose: "1 hour, 2 minutes, 42 seconds" :type parameters: dictionary :returns: the formatted value :rtype: string """ value = track.get_tag_raw(self.name) format = parameters.get('format', 'short') return self.format_value(value, format) @staticmethod def format_value(value, format='short'): """ Formats a length value :param value: the length in seconds :type value: float :param format: verbosity of the output, possible values are: * short: "1:02:42" * long: "1h, 2m, 42s" * verbose: "1 hour, 2 minutes, 42 seconds" :type format: string :returns: the formatted value :rtype: string """ span = TimeSpan(value) text = '' if format == 'verbose': if span.days > 0: text += ngettext('%d day, ', '%d days, ', span.days) % span.days if span.hours > 0: text += ngettext('%d hour, ', '%d hours, ', span.hours) % span.hours text += ngettext('%d minute, ', '%d minutes, ', span.minutes) % span.minutes text += ngettext('%d second', '%d seconds', span.seconds) % span.seconds elif format == 'long': if span.days > 0: # TRANSLATORS: Short form of an amount of days text += _('%dd, ') % span.days if span.hours > 0: # TRANSLATORS: Short form of an amount of hours text += _('%dh, ') % span.hours # TRANSLATORS: Short form of an amount of minutes text += _('%dm, ') % span.minutes # TRANSLATORS: Short form of an amount of seconds text += _('%ds') % span.seconds elif format == 'short': if span.days > 0: # TRANSLATORS: Short form of an amount of days text += _('%dd ') % span.days if span.hours > 0 or text: # always show hours when > 1 day # TRANSLATORS: Time duration (hours:minutes:seconds) text += _('%d:%02d:%02d') % (span.hours, span.minutes, span.seconds) else: # TRANSLATORS: Time duration (minutes:seconds) text += _('%d:%02d') % (span.minutes, span.seconds) else: raise ValueError( 'Invalid argument "%s" passed to parameter ' '"format" for tag "__length", possible arguments are ' '"short", "long" and "verbose"' % format ) return text
[docs]class LengthTagFormatter(TimeTagFormatter): """ A formatter for the length of a track """ def __init__(self): TimeTagFormatter.__init__(self, '__length')
providers.register('tag-formatting', LengthTagFormatter()) class StartOffsetTagFormatter(TimeTagFormatter): """ A formatter for the track's start offset """ def __init__(self): TimeTagFormatter.__init__(self, '__startoffset') providers.register('tag-formatting', StartOffsetTagFormatter()) class StopOffsetTagFormatter(TimeTagFormatter): """ A formatter for the track's stop offset """ def __init__(self): TimeTagFormatter.__init__(self, '__stopoffset') providers.register('tag-formatting', StopOffsetTagFormatter())
[docs]class RatingTagFormatter(TagFormatter): """ A formatter for the rating of a track Will return glyphs representing the rating like ★★★☆☆ """ def __init__(self): TagFormatter.__init__(self, '__rating') def format(self, track, parameters): """ Formats a raw tag value :param track: the track to get the tag from :type track: :class:`xl.trax.Track` :param parameters: optionally passed parameters :type parameters: dictionary :returns: the formatted value :rtype: string """ rating = track.get_rating() maximum = settings.get_option('rating/maximum', 5) filled = '★' * int(rating) empty = '☆' * int(maximum - rating) return ('%s%s' % (filled, empty)).decode('utf-8')
providers.register('tag-formatting', RatingTagFormatter()) class YearTagFormatter(TagFormatter): """ A pseudo-tag that computes the year from the date column """ def __init__(self): TagFormatter.__init__(self, 'year') def format(self, track, parameters): value = track.get_tag_raw('date') if value is not None: try: return value[0].split('-')[0] except Exception: pass return value[0] return _("Unknown") providers.register('tag-formatting', YearTagFormatter()) class DateTagFormatter(TagFormatter): """ A generic formatter for timestamp formatting Will return the localized string for *Today*, *Yesterday* or the respective localized date for earlier dates """ def __init__(self, name): """ :param name: the name of the tag :type name: string """ TagFormatter.__init__(self, name) def format(self, track, parameters): """ Formats a raw tag value :param track: the track to get the tag from :type track: :class:`xl.trax.Track` :param parameters: optionally passed parameters :type parameters: dictionary :returns: the formatted value :rtype: string """ value = track.get_tag_raw(self.name) # TRANSLATORS: Indicates that a track has never been played before text = _('Never') try: last_played = date.fromtimestamp(value) except (TypeError, ValueError): text = _('Never') else: today = date.today() delta = today - last_played if delta.days == 0: text = _('Today') elif delta.days == 1: text = _('Yesterday') else: text = last_played.strftime('%x') return text
[docs]class LastPlayedTagFormatter(DateTagFormatter): """ A formatter for the last time a track was played """ def __init__(self): DateTagFormatter.__init__(self, '__last_played')
providers.register('tag-formatting', LastPlayedTagFormatter()) class DateAddedTagFormatter(DateTagFormatter): """ A formatter for the date a track was added to the collection """ def __init__(self): DateTagFormatter.__init__(self, '__date_added') providers.register('tag-formatting', DateAddedTagFormatter()) class LocationTagFormatter(TagFormatter): """ A formatter for the location of a track, properly sanitized if necessary """ def __init__(self): TagFormatter.__init__(self, '__loc') def format(self, track, parameters): """ Formats a raw tag value :param track: the track to get the tag from :type track: :class:`xl.trax.Track` :param parameters: optionally passed parameters :type parameters: dictionary :returns: the formatted value :rtype: string """ path = track.get_local_path() if path is not None: return path return common.sanitize_url(track.get_tag_raw('__loc')) providers.register('tag-formatting', LocationTagFormatter()) class CommentTagFormatter(TagFormatter): """ A formatter for comments embedded in tracks """ def __init__(self): TagFormatter.__init__(self, 'comment') def format(self, track, parameters): """ Formats a raw tag value :param track: the track to get the tag from :type track: :class:`xl.trax.Track` :param parameters: whether to keep newlines, possible values for "newlines" are: * keep: do not strip newlines (default) * strip: strip newlines :type parameters: dictionary :returns: the formatted value :rtype: string """ value = track.get_tag_raw(self.name) if not value: return '' value = '\n\n'.join(value) newlines = parameters.get('newlines', 'keep') return self.format_value(value, newlines) @staticmethod def format_value(value, newlines='keep'): """ Formats a comment value :param value: the comment text :type value: string :param newlines: whether to keep newlines, possible values are: * keep: do not strip newlines (default) * strip: strip newlines :type newlines: string :returns: the formatted value :rtype: string """ if newlines == 'strip': value = ' '.join(value.splitlines()) return value providers.register('tag-formatting', CommentTagFormatter()) # vim: et sts=4 sw=4