# encoding=utf8 # This file is part of Headphones. # # Headphones 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 3 of the License, or # (at your option) any later version. # # Headphones 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 Headphones. If not, see . """ Path pattern substitution module, see details below for syntax. The pattern matching is loosely based on foobar2000 pattern syntax, i.e. the notion of escaping characters with \' and optional elements enclosed in square brackets [] is taken from there while the substitution variable names are Perl-ish or sh-ish. The following syntax elements are supported: * escaped literal strings, that is everything that is enclosed within single quotes (like 'this'); * substitution variables, which start with dollar sign ($) and extend until next non-alphanumeric+underscore character (like $This and $5_that). * optional elements enclosed in curly braces, which render nonempty value only if any variable or optional inside returned nonempty value, ignoring literals (like {'{'$That'}'}). """ from __future__ import print_function from enum import Enum __author__ = "Andrzej Ciarkowski " class _PatternElement(object): '''ABC for hierarchy of path name renderer pattern elements.''' def render(self, replacement): # type: (Mapping[str,str]) -> str '''Format this _PatternElement into string using provided substitution dictionary.''' raise NotImplementedError() def __ne__(self, other): return not self == other class _Generator(_PatternElement): # pylint: disable=abstract-method '''Tagging interface for "content-generating" elements like replacement or optional block.''' pass class _Replacement(_Generator): '''Replacement variable, eg. $title.''' def __init__(self, pattern): # type: (str) self._pattern = pattern def render(self, replacement): # type: (Mapping[str,str]) -> str res = replacement.get(self._pattern, self._pattern) if res is None: return '' else: return res def __str__(self): return self._pattern @property def pattern(self): return self._pattern def __eq__(self, other): return isinstance(other, _Replacement) and \ self._pattern == other.pattern class _LiteralText(_PatternElement): '''Just a plain piece of text to be rendered "as is".''' def __init__(self, text): # type: (str) self._text = text def render(self, replacement): # type: (Mapping[str,str]) -> str return self._text def __str__(self): return self._text @property def text(self): return self._text def __eq__(self, other): return isinstance(other, _LiteralText) and self._text == other.text class _OptionalBlock(_Generator): '''Optional block will render its contents only if any _Generator in its scope did return non-empty result.''' def __init__(self, scope): # type: ([_PatternElement]) self._scope = scope def render(self, replacement): # type: (Mapping[str,str]) -> str res = [(isinstance(x, _Generator), x.render(replacement)) for x in self._scope] if any((t[0] and t[1] is not None and len(t[1]) != 0) for t in res): return u"".join(t[1] for t in res) else: return u"" def __eq__(self, other): """ :type other: _OptionalBlock """ return isinstance(other, _OptionalBlock) and self._scope == other._scope _OPTIONAL_START = u'{' _OPTIONAL_END = u'}' _ESCAPE_CHAR = u'\'' _REPLACEMENT_START = u'$' def _is_replacement_valid(c): # type: (str) -> bool return c.isalnum() or c == u'_' class _State(Enum): LITERAL = 0 ESCAPE = 1 REPLACEMENT = 2 def _append_literal(scope, text): # type: ([_PatternElement], str) -> None '''Append literal text to the scope BUT ONLY if it's not an empty string.''' if len(text) == 0: return scope.append(_LiteralText(text)) class Warnings(Enum): '''Pattern parsing warnings, as stored withing warnings property of Pattern object after parsing.''' UNCLOSED_ESCAPE = 'Warnings.UNCLOSED_ESCAPE' UNCLOSED_OPTIONAL = 'Warnings.UNCLOSED_OPTIONAL' def _parse_pattern(pattern, warnings): # type: (str,MutableSet[Warnings]) -> [_PatternElement] '''Parse path pattern text into list of _PatternElements, put warnings into the provided set.''' start = 0 # index of current state start char root_scope = [] # here our _PatternElements will reside scope_stack = [root_scope] # stack so that we can return to the outer scope scope = root_scope # pointer to the current list for _OptionalBlock inside_optional = 0 # nesting level of _OptionalBlocks state = _State.LITERAL # current state for i, c in enumerate(pattern): if state is _State.ESCAPE: if c != _ESCAPE_CHAR: # only escape char can get us out of _State.ESCAPE continue _append_literal(scope, pattern[start + 1:i]) state = _State.LITERAL start = i + 1 # after exiting _State.ESCAPE on escape char no more processing of c continue if state is _State.REPLACEMENT: if _is_replacement_valid(c): # only replacement invalid can get us out _State.REPLACEMENT continue scope.append(_Replacement(pattern[start:i])) state = _State.LITERAL start = i # intentional fall-through to _State.LITERAL assert state is _State.LITERAL if c == _ESCAPE_CHAR: _append_literal(scope, pattern[start:i]) state = _State.ESCAPE start = i # no more processing to escape char c continue if c == _REPLACEMENT_START: _append_literal(scope, pattern[start:i]) state = _State.REPLACEMENT start = i # no more processing to replacement char c continue if c == _OPTIONAL_START: _append_literal(scope, pattern[start:i]) inside_optional += 1 new_scope = [] scope_stack.append(new_scope) scope = new_scope start = i + 1 continue if c == _OPTIONAL_END: if inside_optional == 0: # no optional block to end, just treat as literal text continue inside_optional -= 1 _append_literal(scope, pattern[start:i]) scope_stack.pop() prev_scope = scope_stack[-1] prev_scope.append(_OptionalBlock(scope)) scope = prev_scope start = i + 1 # fi # done if state is _State.ESCAPE: warnings.add(Warnings.UNCLOSED_ESCAPE) if inside_optional != 0: warnings.add(Warnings.UNCLOSED_OPTIONAL) if state is _State.REPLACEMENT: root_scope.append(_Replacement(pattern[start:])) else: # don't care about unclosed elements :P _append_literal(root_scope, pattern[start:]) return root_scope class Pattern(object): '''Stores preparsed rename pattern for repeated use. If using the same pattern repeatedly it is much more effective to parse the pattern into Pattern object and use it instead of parsing the textual pattern on each substitution. To use Pattern object for substitution simply call it as it was function providing dictionary as an argument (see __call__()).''' def __init__(self, pattern): # type: (str) self._warnings = set() self._pattern = _parse_pattern(pattern, self._warnings) def __call__(self, replacement): # type: (Mapping[str,str]) -> str '''Execute path rendering/substitution based on replacement dictionary.''' return u"".join(p.render(replacement) for p in self._pattern) def _get_warnings(self): # type: () -> str '''Getter for warnings property.''' return self._warnings warnings = property(_get_warnings, doc="Access warnings raised during pattern parsing") def render(pattern, replacement): # type: (str, Mapping[str,str]) -> (str, AbstractSet[Warnings]) '''Render path name based on replacement pattern and dictionary.''' p = Pattern(pattern) return p(replacement), p.warnings if __name__ == "__main__": # primitive test ;) p = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]}") d = {'$Disc': '', '$Track': '05', '$Artist': u'Grzegżółka', '$Title': u'Błona kapłona', '$Year': '2019'} assert p(d) == u"05 - Grzegżółka - Błona kapłona [2019]"