pythonformattingstring-interpolation

Return placeholder values with formatting if a key is not found


I want to silently ignore KeyErrors and instead replace them with placeholders if values are not found. For example:

class Name:
    def __init__(self, name):
        self.name = name
        self.capitalized = name.capitalize()
    
    def __str__(self):
        return self.name

"hello, {name}!".format(name=Name("bob")) # hello, bob!
"greetings, {name.capitalized}!".format(name=Name("bob")) # greetings, Bob!

# but, if no name kwarg is given...
"hello, {name}!".format(age=34) # hello, {name}!
"greetings, {name.capitalized}!".format(age=34) # greetings, {name.capitalized}!

My goal with this is that I'm trying to create a custom localization package for personal projects (I couldn't find existing ones that did everything I wanted to). Messages would be user-customizable, but I want users to have a flawless experience, so for example, if they make a typo and insert {nmae} instead of {name}, I don't want users to have to deal with errors, but I want to instead signal to them that they made a typo by giving them the placeholder value.

I found several solutions on stackoverflow, but none of them can handle attributes. My first solution was this:

class Default(dict):
    """A dictionary that returns the key itself wrapped in curly braces if the key is not found."""

    def __missing__(self, key: str) -> str:
        return f"{{{key}}}"

But this results in an error when trying to use it with attributes: AttributeError: 'str' object has no attribute 'capitalized', it does print "hello, {name}!" with no issues. Same goes for my second solution using string.Formatter:

class CustomFormatter(string.Formatter):
    def get_value(self, key, args, kwargs):
        try:
            value = super().get_value(key, args, kwargs)
        except KeyError:
            value = f'{{{key}}}'
        except AttributeError:
            value = f'{{{key}}}'
        return value

formatter.format("hello, {name}!", name=Name("bob")) # hello, bob!
formatter.format("greetings, {name.capitalized}!", name=Name("bob")) # greetings, Bob!
formatter.format("hello, {name}!", age=42) # hello, {name}!
formatter.format("greetings, {name.capitalized}!", age=42) # AttributeError: 'str' object has no attribute 'capitalized'

So how could I achieve something like this?

"hello, {name}!".format(name=Name("bob")) # hello, bob!
"greetings, {name.capitalized}!".format(name=Name("bob")) # greetings, Bob!

# but, if no name kwarg is given...
"hello, {name}!".format(age=34) # hello, {name}!
"greetings, {name.capitalized}!".format(age=34) # greetings, {name.capitalized}!

Solution

  • TL;DR

    The best solution is to override get_field instead of get_value in CustomFormatter:

    class CustomFormatter(string.Formatter):
        def get_field(self, field_name, args, kwargs):
            try:
                return super().get_field(field_name, args, kwargs)
            except (AttributeError, KeyError):
                return f"{{{field_name}}}", None
    

    Kuddos to @blhsing for suggesting this solution.

    Details

    The issue is that the AttributeError gets raised when formatter.get_field() is called, not in get_value(), so you also need to override get_field().

    By adding this function to your CustomFormatter class, I was able to get the behaviour you want with {name.capitalized} shown when you pass name="bob" or name=34 instead of name=Name("bob"):

        def get_field(self, field_name, args, kwargs):
            try:
                return super().get_field(field_name, args, kwargs)
            except AttributeError:
                return f"{{{field_name}}}", None
    

    The return value is a tuple, to respect get_field's return value: a tuple with the result, and the key used.

    In action

    >>> formatter = CustomFormatter()
    >>> formatter.format("greetings, {name.capitalized}!", name="bob")
    'greetings, {name.capitalized}!'
    >>> formatter.format("greetings, {name.capitalized}!", name=34)
    'greetings, {name.capitalized}!'
    >>> formatter.format("greetings, {name.capitalized}!", name=Name("bob"))
    'greetings, Bob!'
    >>> formatter.format("{name.capitalized}, you are {age} years old.", name=Name("bob"))
    'Bob, you are {age} years old.'
    

    Tracing the code for deeper understanding

    When I added some debugging print statements, namely:

    class CustomFormatter(string.Formatter):
        def get_value(self, key, args, kwargs):
            print(f"get_value({key=}, {args=}, {kwargs=}")
            ...
        def get_field(self, field_name, args, kwargs):
            print(f"get_field({field_name=}, {args=}, {kwargs=}")
            ...
    

    I could see this log when using name=Name("bob"):

    get_field(field_name='name.capitalized', args=(), kwargs={'name': <__main__.Name object at 0x000002818AA8E8D0>}
    get_value(key='name', args=(), kwargs={'name': <__main__.Name object at 0x000002818AA8E8D0>}
    

    and this log with for age=34 and leaving out name:

    get_field(field_name='name', args=(), kwargs={'age': 34}
    get_value(key='name', args=(), kwargs={'age': 34}
    

    so you see it's your overriden get_value that handles the wrong key, and my overriden get_field that handles the missing attribute.

    Making the code more concise

    As @blhsing pointed out, if you also catch the KeyError in get_field, then you don't need to override get_value at all, leading to the final solution in the TL;DR above.