Home > OS >  Pythonic way to handle default arguments and argument overwrite with many named parameters?
Pythonic way to handle default arguments and argument overwrite with many named parameters?

Time:11-21

On classes/methods that work with several properties, what's the best or more pythonic way to work with default parameters (on object instantiation) and overwrite those defaults on calls to that object's methods?

I'd like to be able to create an object with a set of default parameters (being a large amount of possible parameters) and then, when calling that object's methods, either use those default parameters or be able to easily overwrite any of them in the method call.

I have it already working (example below) but I'd like to know what's the most pythonic way to do it.

Let's illustrate my question:

Class definition:

class SimpleInputText:

    def __init__(self, basicFont, **kwargs):
        self._basicFont = basicFont
        self.text = ''
        self.set_defaults(**kwargs)

    def set_defaults(self, **kwargs):
        self.default_text = kwargs.get('default_text', '')
        self.color = kwargs.get('color', (0, 0, 0))
        self.inactive_color = kwargs.get('inactive_color', (50, 50, 50))
        self.error_color = kwargs.get('error_color', None)
        self.background_color = kwargs.get('background_color', None)
        self.border_color = kwargs.get('border_color', (0, 0, 0))
        self.border_size = kwargs.get('border_size', 0)
        self.border_radius = kwargs.get('border_radius', 0)
        self.padding_left = kwargs.get('padding_left', 0)
        self.padding_top = kwargs.get('padding_top', 0)
        self.padding = kwargs.get('padding', 2)
        self.shadow_offset = kwargs.get('shadow_offset', 0)
        self.shadow_color = kwargs.get('shadow_color', (0, 0, 0))
        # (and more possible properties)

    def input_modal(self, x, y, default_text='', color=None, background_color=None,
                    inactive_color=None, border_color=None, border_size=None,
                    border_radius=None, padding_left=None, padding_top=None, padding=None,
                    shadow_offset=None, shadow_color=None, etc... ):

        # Set specific values if passed, otherwise use defaults
        cursor_char = self.cursor_char if cursor_char is None else cursor_char
        input_type = self.input_type if input_type is None else input_type
        check_type = self.check_type if check_type is None else check_type
        color = self.color if color is None else color
        inactive_color = self.inactive_color if inactive_color is None else inactive_color
        inactive_border_color = self.inactive_border_color if inactive_border_color is None else inactive_border_color
        error_color = self.error_color if error_color is None else error_color
        background_color = self.background_color if background_color is None else background_color
        border_color = self.border_color if border_color is None else border_color
        border_size = self.border_size if border_size is None else border_size
        padding_left = self.padding_left if padding_left is None else padding_left
        padding_top = self.padding_top if padding_top is None else padding_top
        padding = self.padding if padding is None else padding
        border_radius = self.border_radius if border_radius is None else border_radius
        shadow_offset = self.shadow_offset if shadow_offset is None else shadow_offset
        shadow_color = self.shadow_color if shadow_color is None else shadow_color
        # etc...
        # the method uses, from now on, the local versions of the variables
        # (i.e. color and not self.color) to do its work.

This way I can instantiate an inputBox object with specific values and overwrite any of these values in the moment of calling input_modal().

I also considered the possibility of using a dict for self.defaults and then get a merge of the defaults and the parameters:

    def input_modal(self, x, y, default_text='', **kwargs ):
        params = dict(**self.defaults, **kwargs)
        # now use params.color, params.border_size, etc in the method

I'm not sure what's the best approach for this specific use-case (allowing defaults and having a large number of possible parameters due to styling options).

CodePudding user response:

I would use a dataclass here:

from dataclasses import dataclass

@dataclass
class SimpleInputText:
    self._basicFont    : str
    self.text          : str         = ''
    self.default_text  : str         = ''
    self.color         : tuple[int]  = (0, 0, 0)
    self.inactive_color: tuple[int]  = (50, 50, 50)
    # and so on

    def input_modal(self, **kwargs):
        for k,v in kwargs.items():
            if k in self.__dict__:
                self.__dict__[k] = v
            else:
                raise NameError(f'{k} is not an attribute')

This allows you to create your instance with the arguments you need while everything else gets the proper default value; and then to overwrite exactly what you want to overwrite (I added the if/else test because specifying a wrong parameter name would fail silently otherwise)

CodePudding user response:

One option would be to define a descriptor that would store the default value for a given attribute, and could be used for type validation as well. The descriptor could be used to automatically register parameters that can be set via kwargs.

Here is an example that I adapted for this class from an existing class that I had:

from __future__ import annotations

from typing import Optional, Union


class TextAttribute:
    __slots__ = ('default', 'type', 'name')

    def __init__(self, default, type=None):  # noqa
        self.default = default
        self.type = type

    def __set_name__(self, owner, name: str):
        self.name = name
        owner.FIELDS.add(name)

    def __get__(self, instance, owner):
        if instance is None:
            return self
        try:
            return instance.__dict__[self.name]
        except KeyError:
            return self.default

    def __set__(self, instance, value):
        if self.type is not None:
            value = self.type(value)
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        try:
            del instance.__dict__[self.name]
        except KeyError as e:
            raise AttributeError(f'No {self.name!r} attribute was stored for {instance}') from e


class Color:
    # You could implement __get__ and __iter__ to act more like a tuple
    def __init__(self, red: int, green: int, blue: int):
        self.red = red
        self.green = green
        self.blue = blue

    @classmethod
    def normalize(cls, obj: Union[Color, tuple[int, int, int], None]) -> Optional[Color]:
        if isinstance(obj, cls) or obj is None:
            return obj
        return cls(*obj)

    def __repr__(self) -> str:
        return f'<Color({self.red}, {self.green}, {self.blue})>'


class SimpleInputText:
    FIELDS = set()

    default_text = TextAttribute('', str)
    color = TextAttribute(Color(0, 0, 0), Color.normalize)
    inactive_color = TextAttribute(Color(50, 50, 50), Color.normalize)
    error_color = TextAttribute(None, Color.normalize)
    padding = TextAttribute(2, int)

    def __init__(self, basic_font, **kwargs):
        self.basic_font = basic_font
        self.text = ''
        self._update_attrs(**kwargs)

    def _update_attrs(self, **kwargs):
        bad = {}
        for key, val in kwargs.items():
            if key in self.FIELDS:
                setattr(self, key, val)
            else:
                bad[key] = val
        if bad:
            raise ValueError('Invalid text attributes - unsupported args: '   ', '.join(sorted(bad)))

    def input_modal(self, x, y, **kwargs):
        self._update_attrs(**kwargs)

The Color class is optional, but it makes it easier to validate the arguments for those attrs. A type/validator does not need to be provided for every TextAttribute.

One of the benefits of the approach used by _update_attrs is that the execution time is based on the number of arguments provided, not the total number of potential arguments/attributes. If you needed to avoid side effects caused by a mix of valid and invalid args passed to input_modal, then that method could be refactored to split/reject bad args before any setattr calls.

Example usage:

>>> SimpleInputText('tahoma', padding=5, foo='bar')
Traceback (most recent call last):
...
ValueError: Invalid text attributes - unsupported args: foo

>>> sit = SimpleInputText('tahoma', padding=5)

>>> sit.padding
5

>>> sit.default_text
''

>>> sit.color
<Color(0, 0, 0)>

I only defined a subset of the attributes that you included, but you could add them all.

If you wanted to annotate the signatures of __init__ and init_modal to indicate the supported parameters, you could so do with typing.overload, but it can be difficult to maintain.

One of the other benefits of using a descriptor like this is that the same type checking that applies when setting the attributes via _update_attrs happens when assigning directly as well:

>>> sit.color = (1, 2, 3)

>>> sit.color
<Color(1, 2, 3)>

>>> sit.color = 3
Traceback (most recent call last):
...
TypeError: __main__.Color() argument after * must be an iterable, not int

If you don't want to store the overrides provided to init_modal on the SimpleInputText object, then you could potentially use ChainMap to handle the overrides within that method:

def __getitem__(self, item):
    return getattr(self, item)

def init_modal(self, x, y, **kwargs):
    settings = ChainMap(kwargs, self)
    ...

This would have the potential downside of not validating input keys/types, but it would avoid the potentially expensive loop / new dict each time it is called. Example with that addition:

>>> sit = SimpleInputText('tahoma', padding=5)

>>> settings = ChainMap({'padding': 10}, sit)

>>> settings['padding']
10

>>> settings['color']
<Color(0, 0, 0)>

Another approach worth mentioning is provided in this question: Class with too many parameters: better design strategy?

  • Related