import re
from qtpy import QtWidgets as Qt
from .enums import Validation, Labelling
from .utils import callable, first_of_type
from .validators import DataRequired
__author__ = 'Juan Manuel Bermúdez Cabrera'
[docs]class Field(Qt.QWidget):
"""Base class for input fields, isn't generally used directly.
A field is usually composed of a label and an input component. The field's
label position can be changed individually by modifying `labelling` property
or globally by calling :func:`~campos.enums.HasCurrent.set_current` on
:class:`~campos.enums.Labelling` enum. It goes the same way with field's
validation, see :class:`~campos.enums.Validation` enum for possible
validation mechanisms.
Subclasses must implement :func:`has_data` method and :attr:`change_signal`
property.
:param name: text to identify the field inside forms or other contexts,
must be a valid variable name, it defaults to
``field{consecutive_number}``
:type name: :class:`str`
:param text: text to show in field's label, it defaults to ``name``
:type text: :class:`str`
:param description: useful short information about the field, usually shown
as a tooltip
:type description: :class:`str`
:param default: default value when field is shown by first time
:param on_change: handler to call when field's value changes, this is a
shortcut and it only supports one handler at a time,
if you want to connect multiple handlers you should use
``field.change_signal.connect(handler)`` for each handler
:type on_change: callable
:param labelling: field's label position, see
:class:`~campos.enums.Labelling` for possible values,
defaults to 'current'
:type labelling: :class:`str` or :class:`~campos.enums.Labelling`
:param validation: field's validation mechanism, see
:class:`~campos.enums.Validation` for possible values,
defaults to 'current'
:type validation: :class:`str` or :class:`~campos.enums.Validation`
:param validators: validators used to process field's value when
validation is invoked
:type validators: iterable of :class:`~campos.validators.Validator`
:param required: marks this field as required or not, this a shortcut for
``field.validators.append(DataRequired())``
:type required: :class:`bool`
:param message: text to show if field is invalid, if set, this message has
priority over validators' messages
:type message: :class:`str`
"""
_FIELDS_COUNT = 0
_ID_PATTERN = r'[a-z_]+[a-z0-9_]*'
def __init__(self, *args, name='', text='', description='', default=None,
on_change=None, labelling='current', validation='current',
validators=(), required=False, message=None):
super(Field, self).__init__(*args)
Field._FIELDS_COUNT += 1
self.default = default
try:
self.value = self.default
except TypeError:
msg = 'Expecting valid default value for {}, got {}'
msg = msg.format(type(self).__name__, self.default)
raise TypeError(msg)
self._name = None
self.name = name
self.text = text if text else self.name.capitalize().replace('_', ' ')
self.description = description if description else text
self._labelling = None
self.labelling = labelling
self._on_change = None
self.on_change = on_change
self.valid = True
self._validation = None
self.validation = validation
self.errors = []
self.validators = []
self.message = message
for v in validators:
if callable(v):
self.validators.append(v)
else:
raise ValueError('Expecting callable, got {}'.format(v))
self.required = required
@property
def name(self):
"""Text to identify the field inside forms or other contexts,
must be a valid variable name, it defaults to
``field{consecutive_number}``
:type: :class:`str`
:raises ValueError: if an invalid field name is given
"""
return self._name
@name.setter
def name(self, value):
if not value:
self._name = 'field{}'.format(self._FIELDS_COUNT)
elif re.fullmatch(self._ID_PATTERN, value, re.IGNORECASE):
self._name = value
else:
msg = 'Expecting valid variable name, got {}'.format(value)
raise ValueError(msg)
@property
def text(self):
"""Text to show in the field's label.
:type: :class:`str`
"""
raise NotImplementedError
@text.setter
def text(self, value):
raise NotImplementedError
@property
def description(self):
"""Useful short information about the field, usually shown as a tooltip.
:type: :class:`str`
"""
raise NotImplementedError
@description.setter
def description(self, value):
raise NotImplementedError
@property
def required(self):
"""Marks this field as required or not, this a shortcut for
``field.validators.append(DataRequired())``
:type: :class:`bool`
"""
return first_of_type(self.validators, DataRequired) is not None
@required.setter
def required(self, value):
validator = first_of_type(self.validators, DataRequired)
if value:
if validator is None:
self.validators.append(DataRequired())
else:
if validator is not None:
self.validators.remove(validator)
@property
def validation(self):
"""Field's validation mechanism, see :class:`~campos.enums.Validation`
for possible values.
:type: :class:`str` or :class:`~campos.enums.Validation`
"""
return self._validation
@validation.setter
def validation(self, value):
previous = self._validation
self._validation = Validation.get_member(value)
if self._validation != previous:
if self._validation == Validation.INSTANT:
self.change_signal.connect(self._validation_cb)
elif previous is not None:
self.change_signal.disconnect(self._validation_cb)
@property
def labelling(self):
"""Field's label position, see :class:`~campos.enums.Labelling` for
possible values.
:type: :class:`str` or :class:`~campos.enums.Labelling`
"""
raise NotImplementedError
@labelling.setter
def labelling(self, value):
raise NotImplementedError
@property
def on_change(self):
"""Handler to call when field's value changes, this is a shortcut and
it only supports one handler at a time, if you want to connect multiple
handlers you should use ``field.change_signal.connect(handler)``
for each handler.
To disconnect a connected handler just set ``on_change = None``
:type: callable or None
"""
return self._on_change
@on_change.setter
def on_change(self, callback):
if self._on_change is not None: # disconnect the previous handler
self.change_signal.disconnect(self._on_change)
self._on_change = None
if callable(callback):
self.change_signal.connect(callback)
self._on_change = callback
elif callback is not None:
msg = 'Expecting callable, got {}'.format(type(callback))
raise ValueError(msg)
@property
def value(self):
"""Field's current value"""
raise NotImplementedError
@value.setter
def value(self, value):
raise NotImplementedError
[docs] def has_data(self):
"""Check if the field has any data.
:returns: True only if the field contains any data
:rtype: :class:`bool`
"""
raise NotImplementedError
@property
def change_signal(self):
"""Returns a valid Qt signal which is fired whenever the field's
value changes
:rtype: callable
"""
raise NotImplementedError
[docs] def validate(self):
"""Validates field's current value using current validators. After
validation all errors are stored in ``errors`` list in the form of
:class:`ValueError` objects, if the field is valid ``errors`` will be
empty.
:return: if the field is valid or not
:rtype: :class:`bool`
"""
self.errors.clear()
if not (self.required or self.has_data()):
self.valid = True
else:
for validator in self.validators:
try:
validator(self)
except ValueError as e:
self.errors.append(e)
self.valid = len(self.errors) == 0
def _validation_cb(self):
if self.validation == Validation.INSTANT:
self.validate()
[docs]class BaseField(Field):
"""More complete base class for fields, implementing a common use case
scenario.
This class assumes that a field is composed by a label, a central component
(usually where the value is entered) and other label used to show validation
errors.
In order to create new fields following this structure is only necessary to
implement :attr:`value` and :attr:`main_component` properties.
:class:`Field` should be used as base class to create fields without
this structure.
"""
def __init__(self, *args, **kwargs):
self.label = Qt.QLabel('')
self.error_label = Qt.QLabel('')
self.error_label.setStyleSheet('color: rgb(255, 0, 0);')
self.field_layout = None
super(BaseField, self).__init__(*args, **kwargs)
@property
def main_component(self):
"""Returns a valid QWidget or QLayout holding the main part of the
field(without text and error labels)
:rtype: QWidget or QLayout
"""
raise NotImplementedError
@property
def text(self):
return self.label.text()
@text.setter
def text(self, value):
self.label.setText(value)
@property
def description(self):
if isinstance(self.main_component, Qt.QWidget):
return self.main_component.toolTip()
# it's a layout
for i in range(self.main_component.count()):
item = self.main_component.itemAt(i)
if isinstance(item, Qt.QWidget) and item.toolTip():
return item.toolTip()
return ''
@description.setter
def description(self, value):
if isinstance(self.main_component, Qt.QWidget):
self.main_component.setToolTip(value)
else:
# it's a layout
for i in range(self.main_component.count()):
item = self.main_component.itemAt(i)
if isinstance(item, Qt.QWidget) and not item.toolTip():
item.setToolTip(value)
@property
def labelling(self):
return self._labelling
@labelling.setter
def labelling(self, value):
new = Labelling.get_member(value)
if self.labelling != new:
# create new layout
if new == Labelling.LEFT:
klass = Qt.QHBoxLayout
elif new == Labelling.TOP:
klass = Qt.QVBoxLayout
else:
msg = '{} not supported by {}'.format(new, self.__class__)
raise ValueError(msg)
layout = klass()
layout.setSpacing(5)
layout.setContentsMargins(0, 0, 0, 0)
# if it was a previous layout then remove it's children and add them
# to the new layout
if self.field_layout is not None:
self.field_layout.takeAt(2) # error label
self.field_layout.takeAt(1) # main component
self.field_layout.takeAt(0) # label
# re-parent existing layout
Qt.QWidget().setLayout(self.field_layout)
# add children to new layout
layout.addWidget(self.label)
if isinstance(self.main_component, Qt.QWidget):
layout.addWidget(self.main_component)
else:
layout.addLayout(self.main_component)
layout.addWidget(self.error_label)
# stretch main component
layout.setStretch(1, 1)
# set layout
self.field_layout = layout
self.setLayout(layout)
self._labelling = new
def validate(self):
super(BaseField, self).validate()
if self.error_label is not None:
msg = ''
if not self.valid:
msg = self.message if self.message else str(self.errors.pop())
self.error_label.setText(msg)