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?