from copy import copy from datetime import date, datetime, timedelta, time from types import FunctionType from collections import Iterable, defaultdict from openpyxl.cell import WriteOnlyCell from openpyxl.formatting import Rule from openpyxl.styles import NamedStyle from openpyxl.utils import get_column_letter from openpyxl.worksheet.datavalidation import DataValidation from openpyxl_templates.exceptions import OpenpyxlTemplateException, CellException from openpyxl_templates.styles import ExtendedStyle from openpyxl_templates.utils import Typed, FakeCell class ColumnIndexNotSet(OpenpyxlTemplateException): def __init__(self, column): super(ColumnIndexNotSet, self).__init__( "Column index not set for column '%s'. This should be done automatically by the TableSheet." % column ) class ObjectAttributeNotSet(OpenpyxlTemplateException): def __init__(self, column): super(ObjectAttributeNotSet, self).__init__( "object_attribute not set for column '%s'. This should be done automatically by the TableSheet. " "The attributed must be assigned explicitly if added after class declaration" % column ) DEFAULT_COLUMN_WIDTH = 8.43 class BlankNotAllowed(CellException): def __init__(self, cell): super(BlankNotAllowed, self).__init__("The cell '%s' is not allowed to be empty." % cell.coordinate) class RowStyle: cell_style = Typed("cell_style", expected_types=[str, NamedStyle, ExtendedStyle], allow_none=True) data_validation = Typed("data_validation", expected_type=DataValidation, allow_none=True) conditional_formatting = Typed("conditional_formatting", expected_type=Rule, allow_none=True) def __init__(self, row_type, getter=None, cell_style=None, conditional_formatting=None, data_validation=None): self.row_type=row_type self.getter = getter self.cell_style = cell_style self.conditional_formatting = conditional_formatting self.data_validation = data_validation class TableColumn(object): _column_index = None # Reading/writing properties _object_attribute = Typed("_object_attribute", expected_type=str, allow_none=True) getter = None default = None # internal value not excel allow_blank = Typed("allow_blank", expected_type=bool, value=True) ignore_forced_text = Typed("ignore_forced_text", expected_type=bool, value=True) # Column rendering properties _header = Typed("header", expected_type=str, allow_none=True) width = Typed("width", expected_types=(int, float), value=DEFAULT_COLUMN_WIDTH * 2) hidden = Typed("hidden", expected_type=bool, value=False) group = Typed("group", expected_type=bool, value=False) header_style = Typed("header_style", expected_types=[str, ExtendedStyle], value="Header") freeze = Typed("freeze", expected_type=bool, value=False) # Cell rendering properties cell_style = Typed("cell_style", expected_types=[str, ExtendedStyle], value="Row") data_validation = Typed("data_validation", expected_type=DataValidation, allow_none=True) conditional_formatting = Typed("conditional_formatting", expected_type=Rule, allow_none=True) row_styles = None BLANK_VALUES = (None, "") def __init__(self, header=None, object_attribute=None, source=None, width=None, hidden=None, group=None, data_validation=None, conditional_formatting=None, default=None, allow_blank=None, ignore_forced_text=None, header_style=None, cell_style=None, freeze=False, getter=None, row_styles=None): self._header = header self.width = width self.hidden = hidden self.group = group self.default = default # Make sure the default value is valid if self.default is not None: self._to_excel(default) self.allow_blank = allow_blank self.ignore_forced_text = ignore_forced_text self._object_attribute = object_attribute self.source = source if type(header_style) == ExtendedStyle and header_style.base is None: header_style.base = self.header_style self.header_style = header_style self.getter = getter or self.getter self.getters = defaultdict(lambda: self.getter) self.cell_style = cell_style or self.cell_style self.cell_styles = defaultdict(lambda: self.cell_style) self.data_validation = data_validation self.data_validations = defaultdict(lambda: self.data_validation) self.conditional_formatting = conditional_formatting self.conditional_formattings = defaultdict(lambda: self.conditional_formatting) self.add_row_style(*(row_styles or self.row_styles or [])) self.freeze = freeze def add_row_style(self, *row_styles): for row_style in row_styles: row_type = row_style.row_type if row_style.getter is not None: self.getters[row_type] = row_style.getter cell_style = row_style.cell_style if cell_style is not None: cell_style = copy(cell_style) if type(cell_style) == ExtendedStyle and not cell_style.base: cell_style.base = self.cell_style if type(self.cell_style) == str else self.cell_style.name self.cell_styles[row_type] = cell_style if row_style.data_validation is not None: self.data_validations[row_type] = row_style.data_validation if row_style.conditional_formatting is not None: self.conditional_formattings[row_type] = row_style.conditional_formatting def get_value_from_object(self, obj, row_type=None): getter = self.getters[row_type] if getter: return getter(self, obj) if isinstance(obj, (list, tuple)): return obj[self.column_index - 1] if isinstance(obj, dict): return obj[self.object_attribute] return getattr(obj, self.object_attribute, None) def _to_excel(self, value, row_type=None): if value in self.BLANK_VALUES: if self.default is not None: return self.to_excel(self.default, row_type=row_type) if self.allow_blank: return None raise BlankNotAllowed(WriteOnlyCell()) return self.to_excel(value) def to_excel(self, value, row_type=None): return value def _from_excel(self, cell): value = cell.value if self.ignore_forced_text and isinstance(value, str) and value.startswith("'"): value = value[1:] if value in self.BLANK_VALUES: if not self.allow_blank: raise BlankNotAllowed(cell=cell) return self.default return self.from_excel(cell, value) def from_excel(self, cell, value): return value def prepare_worksheet(self, worksheet): for data_validation in set(self.data_validations.values()): if data_validation: worksheet.add_data_validation(data_validation) def create_header(self, worksheet, style_set): header = WriteOnlyCell(ws=worksheet, value=self.header) if self.header_style: style_set.style_cell(header, self.header_style) return header def create_cell(self, worksheet, style_set, value=None, row_type=None): cell = WriteOnlyCell( worksheet, value=self._to_excel(value if value is not None else self.default, row_type=row_type) ) cell_style = self.cell_styles[row_type] if cell_style: style_set.style_cell(cell, cell_style) return cell def post_process_cell(self, worksheet, style_set, cell, row_type=None): data_validation = self.data_validations[row_type] if data_validation: data_validation.add(cell) conditional_formatting = self.conditional_formattings[row_type] if conditional_formatting: worksheet.conditional_formatting.add(cell, conditional_formatting) def post_process_worksheet(self, worksheet, style_set, first_row, last_row, data_range): column_dimension = worksheet.column_dimensions[self.column_letter] # Hiding of grouped columns is handled on worksheet level. if not self.group: column_dimension.hidden = self.hidden column_dimension.width = self.width @property def header(self): return self._header or self._object_attribute or "Column%d" % self.column_index @property def column_index(self): if self._column_index is None: raise ColumnIndexNotSet(self) return self._column_index @column_index.setter def column_index(self, value): self._column_index = value @property def column_letter(self): return get_column_letter(self.column_index) @property def object_attribute(self): if self._object_attribute is None: raise ObjectAttributeNotSet(self) return self._object_attribute @property def styles(self): return tuple({self.header_style, self.cell_style, *tuple(self.cell_styles.values())} - {None}) def __str__(self): return "%s(%s)" % (self.__class__.__name__, self._header or self._object_attribute or "") def __repr__(self): return str(self) class StringToLong(CellException): def __init__(self, cell): super(StringToLong, self).__init__( "Value '%s' in cell '%s' is too long." % (cell.value, cell.coordinate) ) class CharColumn(TableColumn): max_length = Typed("max_length", expected_type=int, allow_none=True) def __init__(self, header=None, max_length=None, **kwargs): super(CharColumn, self).__init__(header=header, **kwargs) self.max_length = max_length def from_excel(self, cell, value): if value is None: return None value = str(value) if self.max_length is not None and len(value) > self.max_length: raise StringToLong(cell) return value def to_excel(self, value, row_type=None): if value is None: return "" return str(value) class TextColumn(CharColumn): def __init__(self, **kwargs): kwargs.setdefault("cell_style", "Row, text") super(TextColumn, self).__init__(**kwargs) class UnableToParseException(CellException): type = None def __init__(self, cell=None, value=None): if cell: message = "Unable to convert value '%s' of cell '%s' to %s." % (cell.value, cell.coordinate, self.type) else: message = "Unable to convert value '%s' to '%s'" % (value, self.type) super(UnableToParseException, self).__init__( message ) class UnableToParseBool(UnableToParseException): type = "boolean" class BoolColumn(TableColumn): excel_true = Typed(name="excel_true", value=True, expected_types=(str, int, float, bool)) excel_false = Typed(name="excel_false", value=False, expected_types=(str, int, float, bool)) list_validation = Typed("list_validation", expected_type=bool, value=True) strict = Typed("strict", expected_type=bool, value=False) def __init__(self, header=None, excel_true=None, excel_false=None, list_validation=None, strict=None, **kwargs): self.excel_true = excel_true self.excel_false = excel_false self.list_validation = list_validation self.strict = strict super(BoolColumn, self).__init__(header=header, **kwargs) if self.list_validation and not self.data_validation: self.data_validation = DataValidation( type="list", formula1="\"%s\"" % ",".join(( str(self.excel_true if self.excel_true is not True else "TRUE"), str(self.excel_false if self.excel_false is not False else "FALSE") )) ) def to_excel(self, value, row_type=None): return self.excel_true if value else self.excel_false def from_excel(self, cell, value): if isinstance(value, bool): return value if value == self.excel_true: return True if value == self.excel_false: return False if self.strict: raise UnableToParseBool(cell) return bool(value) class UnableToParseFloat(UnableToParseException): type = "float" class FloatColumn(TableColumn): def __init__(self, **kwargs): kwargs.setdefault("cell_style", "Row, decimal") kwargs.setdefault("default", 0.0) super(FloatColumn, self).__init__(**kwargs) def to_excel(self, value, row_type=None): try: return float(value) except (ValueError, TypeError): raise UnableToParseFloat(value=value) def from_excel(self, cell, value): try: return float(value) except (ValueError, TypeError): raise UnableToParseFloat(cell=cell) class UnableToParseInt(UnableToParseException): type = "int" class RoundingRequired(CellException): def __init__(self, cell): super(RoundingRequired, self).__init__( "The value '%s' in cell '%s' cannot be converted to an integer without rounding the value. Enable " "round_value to do this automatically." % (cell.value, cell.coordinate) ) class IntColumn(FloatColumn): round_value = Typed("round_value", expected_type=bool, value=True) def __init__(self, header=None, round_value=None, **kwargs): kwargs.setdefault("cell_style", "Row, integer") kwargs.setdefault("default", 0) super(IntColumn, self).__init__(header=header, **kwargs) self.round_value = round_value def to_excel(self, value, row_type=None): try: f = float(value) i = round(f, 0) if i != f and not self.round_value: raise RoundingRequired(FakeCell(value=value)) return int(i) except (ValueError, TypeError): raise UnableToParseInt(value=value) def from_excel(self, cell, value): try: f = float(value) i = round(f, 0) if i != f and not self.round_value: raise RoundingRequired(cell=cell) return int(i) except (ValueError, TypeError): raise UnableToParseInt(cell) class IllegalChoice(CellException): def __init__(self, cell, choices): super(IllegalChoice, self).__init__( "The value '%s' in cell '%s' is not a legal choices. Choices are %s." % ( cell.value, cell.coordinate, choices ) ) class ChoiceColumn(TableColumn): list_validation = Typed(name="list_validation", value=True, expected_type=bool) choices = Typed(name="choices", expected_type=Iterable) to_excel_map = None from_excel_map = None def __init__(self, header=None, choices=None, list_validation=None, **kwargs): self.choices = tuple(choices) if choices else None self.list_validation = list_validation self.to_excel_map = {internal: excel for internal, excel in self.choices} self.from_excel_map = {excel: internal for internal, excel in self.choices} # Setup maps before super().__init__() to validation of default value. super(ChoiceColumn, self).__init__(header=header, **kwargs) if self.list_validation and not self.data_validation: self.data_validation = DataValidation( type="list", formula1="\"%s\"" % ",".join('%s' % str(excel) for internal, excel in self.choices) ) def to_excel(self, value, row_type=None): if value not in self.to_excel_map: if self.default is not None: value = self.default if value not in self.to_excel_map: raise IllegalChoice(FakeCell(value), tuple(self.to_excel_map.keys())) return self.to_excel_map[value] def from_excel(self, cell, value): if value not in self.from_excel_map: if self.default is not None: return self.default if value not in self.from_excel_map: raise IllegalChoice(cell, tuple(self.from_excel_map.keys())) return self.from_excel_map[value] class FortnumChoiceColumn(ChoiceColumn): def __init__(self, fortnum, **kwargs): kwargs["choices"] = ((f, str(f)) for f in fortnum) super(FortnumChoiceColumn, self).__init__(**kwargs) class UnableToParseDatetime(UnableToParseException): type = "datetime" class DatetimeColumn(TableColumn): SECONDS_PER_DAY = 24 * 60 * 60 def __init__(self, **kwargs): kwargs.setdefault("cell_style", "Row, date") kwargs.setdefault("header_style", "Header, center") super(DatetimeColumn, self).__init__(**kwargs) def from_excel(self, cell, value): if isinstance(value, (datetime, date)): return value if type(value) in (int, float): # Excel dates start at 1900-01-01 if value < 1: raise UnableToParseDatetime(cell) result = datetime(year=1900, month=1, day=1) + timedelta(days=value - 2) # Excel incorrectly assumes 1900 to be a leap year. if value < 61: result += timedelta(days=1) return result raise UnableToParseDatetime(cell) def to_excel(self, value, row_type=None): if type(value) == date: value = datetime.combine(value, time.min) if not isinstance(value, datetime): raise UnableToParseDatetime(value=value) delta = (value - datetime(year=1900, month=1, day=1, tzinfo=value.tzinfo)) value = delta.days + delta.seconds / self.SECONDS_PER_DAY + 2 # Excel incorrectly assumes 1900 to be a leap year. if value < 61: if value < 1: raise UnableToParseDatetime(value=value) value -= 1 return value class UnableToParseDate(UnableToParseException): type = "Row, date" class DateColumn(DatetimeColumn): def from_excel(self, cell, value): try: return super(DateColumn, self).from_excel(cell, value).date() except UnableToParseDatetime: raise UnableToParseDate(cell=cell) def to_excel(self, value): return int(super(DateColumn, self).to_excel(value)) class YearColumn(DateColumn): def __init__(self, **kwargs): kwargs.setdefault("cell_style", "Row, year") super().__init__(**kwargs) class UnableToParseTime(UnableToParseException): type = "time" class TimeColumn(DatetimeColumn): def __init__(self, **kwargs): kwargs.setdefault("cell_style", "Row, time") super(TimeColumn, self).__init__(**kwargs) def from_excel(self, cell, value): if type(value) == time: return value try: return super(TimeColumn, self).from_excel(cell, value).time() except UnableToParseDatetime: raise UnableToParseTime(cell) def to_excel(self, value): _type = type(value) if value is None: return None if _type == time: return value if _type == datetime: return value.time() if _type == date: return time.min class NoFormula(OpenpyxlTemplateException): def __init__(self): super(NoFormula, self).__init__("No formula specified for FormulaColumn.") class FormulaColumn(TableColumn): formula = Typed(name="formula", expected_type=str, allow_none=True) def __init__(self, formula=None, **kwargs): self.formula = formula if not self.formula: raise NoFormula() super(FormulaColumn, self).__init__(**kwargs) def get_value_from_object(self, obj, row_type=None): return self.formula class EmptyColumn(TableColumn): def get_value_from_object(self, obj, row_type=None): return None