bgusach
bgusach

Reputation: 15165

Format a string with custom placeholder, dot-access for objects, and error tolerant

I need to fill a small string template. My requirements are the following:

I thought the Template class in the module string would do the trick: it fulfills point 1 (placeholder) and 3 (error tolerant), but unfortunately it does not support the "dot-access".

>>> from string import Template
>>> t = Template('$(obj.get)')
>>> t.substitute(obj=dict)
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "C:\Development\CDBServer_active\lib\string.py", line 172, in substitute
    return self.pattern.sub(convert, self.template)
  File "C:\Development\CDBServer_active\lib\string.py", line 169, in convert
    self._invalid(mo)
  File "C:\Development\CDBServer_active\lib\string.py", line 146, in _invalid
    (lineno, colno))
ValueError: Invalid placeholder in string: line 1, col 1

Is there any way to do this without 3rd-party libraries or without writing my own code?

Upvotes: 2

Views: 1010

Answers (2)

Ashwini Chaudhary
Ashwini Chaudhary

Reputation: 250961

You can override the Template class and define your own pattern and safe_substitute method to get the desired behavior:

from string import Template


class MyTemplate(Template):
    pattern = r"""
    \$(?:
      (?P<escaped>\$)|   # Escape sequence of two delimiters
      (?P<named>[_a-z][_.a-z0-9]*)|   # delimiter and a Python identifier
      \((?P<braced>[_a-z][_.a-z0-9]*)\)|   # delimiter and a braced identifier
      (?P<invalid>)              # Other ill-formed delimiter exprs
    )
    """

    def safe_substitute(*args, **kws):
        if not args:
            raise TypeError("descriptor 'safe_substitute' of 'Template' object "
                            "needs an argument")
        self, args = args[0], args[1:]  # allow the "self" keyword be passed
        if len(args) > 1:
            raise TypeError('Too many positional arguments')
        if not args:
            mapping = kws
        elif kws:
            mapping = _multimap(kws, args[0])
        else:
            mapping = args[0]
        # Helper function for .sub()

        def convert(mo):
            named = mo.group('braced')
            if named is not None:
                try:
                    if '.' not in named:
                        return '%s' % (mapping[named],)
                    else:
                        attrs = named.split('.')
                        named, attrs = attrs[0], attrs[1:]
                        if not named.strip() or not all(attr.strip() for attr in attrs):
                            #  handle cases like foo. foo.bar..spam
                            raise Exception()
                        return '%s' % reduce(lambda x, y: getattr(x, y), attrs, mapping[named])
                except Exception as e:
                    return mo.group()
            if mo.group('escaped') is not None:
                return self.delimiter
            if mo.group('invalid') is not None:
                return mo.group()
            raise ValueError('Unrecognized named group in pattern',
                             self.pattern)
        return self.pattern.sub(convert, self.template)

Demo:

class A(object):
    def __init__(self, val):
        self.val = val
    def __getattr__(self, attr):
        return A(self.val * 2)
    def __repr__(self):
        return str(self.val)

>>> t = MyTemplate('$(obj.get) $(foo) $(spam.a.b.c.d) $(A.__getattr__)')
>>> t.safe_substitute(obj=dict, foo=1, spam=A(10), A=A)
"<method 'get' of 'dict' objects> 1 160 <unbound method A.__getattr__>"
>>> t.safe_substitute(obj=A(100), foo=1, spam=A(10), A=A)
'200 1 160 <unbound method A.__getattr__>'

The changes in safe_subsitute method is that if . is present in the identifier then try to calculate its value using reduce with getattr:

attrs = named.split('.')
named, attrs = attrs[0], attrs[1:]
if not named.strip() or not all(attr.strip() for attr in attrs):
    #  handle cases like foo. foo.bar..spam 
    raise Exception()
return '%s' % reduce(lambda x, y: getattr(x, y), attrs, mapping[named])

Note that currently the code will also replace the values of named groups like $obj.get, if you don't want that behavior remove the named group from the regex and change the first line of function convert to:

named = mo.group('braced')

Upvotes: 1

Eric
Eric

Reputation: 97591

Just translate format strings?

def translate(fmt):
    # escape their markers
    fmt = fmt.replace('{', '{{').replace('}', '}}')

    # translate our markers
    fmt = re.sub(r'\$\((.+?)\)', r'{\1}', fmt)

    # unescape out markers
    fmt = fmt.replace('$$', '$')

    return fmt

obj = lambda: this_is_a_hack
obj.it = 123
translate("$(testing.it)").format(testing=obj)

Upvotes: 1

Related Questions