Search code examples
pythonstringtry-catch

How to escape $ on Python string Template class?


Introduction

The string module has a Template class, that lets you make substitutions in a string using a mapping object, for instance:

>>> string.Template('var is $var').substitute({'var': 1})
'var is 1'

The substitute method may raise a KeyError exception, if an attempt is made to substitute an element that is missing from the mapping, for instance

>>> string.Template('var is $var and foo is $foo').substitute({'var': 1})
KeyError: 'foo'

or may raise a ValueError, if the template string is invalid, e.g. it contains a $ character followed by a space:

>>> string.Template('$ var is $var').substitute({'var': 1})
ValueError: Invalid placeholder in string: line 1, col 1

The Problem

Given a template string and a mapping, I want to determine whether all place-holders in the template would be substituted. For this, I would try to make the substitution and catch any KeyError exception:

def check_substitution(template, mapping):
    try:
        string.Template(template).substitute(mapping)
    except KeyError:
        return False
    except ValueError:
        pass
    return True

But this doesn't work, because if the template is invalid and a ValueError is raised, subsequent KeyErrors aren't caught:

>>> check_substitution('var is $var and foo is $foo', {'var': 1})
False
>>> check_substitution('$ var is $var and foo is $foo', {'var': 1})
True

but I don't care about ValueErrors. So, what would be the right approach to this problem?


Solution

  • The docs say that you can replace the pattern as long as it contains all necessary named groups:

    import re
    from string import Template
    
    
    class TemplateIgnoreInvalid(Template):
        # override pattern to make sure `invalid` never matches
        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<invalid>^$)            # never matches (the regex is not multilined)
        )
        """ % dict(delim=re.escape(Template.delimiter), id=Template.idpattern)
    
    
    def check_substitution(template, **mapping):
        try:
            TemplateIgnoreInvalid(template).substitute(mapping)
        except KeyError:
            return False
        else:
            return True
    

    Tests

    f = check_substitution
    assert f('var is $var', var=1)
    assert f('$ var is $var', var=1)
    assert     f('var is $var and foo is $foo', var=1, foo=2)
    assert not f('var is $var and foo is $foo', var=1)
    assert     f('$ var is $var and foo is $foo', var=1, foo=2)
    assert not f('$ var is $var and foo is $foo', var=1)
    # support all invalid patterns
    assert f('var is $var and foo is ${foo', var=1)
    assert f('var is $var and foo is ${foo', var=1, foo=2) #NOTE: problematic API
    assert     f('var is $var and foo is ${foo and ${baz}', var=1, baz=3)
    assert not f('var is $var and foo is ${foo and ${baz}', var=1)
    

    It works for all invalid occurences of the delimiter ($).

    The examples show that ignoring invalid patterns conceals simple typos in the template so it is not a good API.