from __future__ import annotations
import colorsys
from collections import namedtuple
from dataclasses import dataclass
from enum import Enum
from math import isinf, isnan
from typing import Any, Generic, TypeVar
from collections.abc import Callable, Iterable
import pandas
T = TypeVar('T')
class ResolvedValue(Generic[T]):
"""
Interface for values that can be resolved on first access, or even on each access.
"""
def get_value(self) -> T:
pass
def __eq__(self, other):
if isinstance(other, ResolvedValue):
return self.get_value() == other.get_value()
return self.get_value() == other
class LabeledEnum(Enum):
@property
def label(self: LabeledEnum):
label = getattr(self, '_label', None)
if label is None:
if hasattr(self.__class__, '_LABELS'):
labels = self.__class__._LABELS
else:
labels = self.get_labels()
self.__class__._LABELS = labels
if labels:
if self in labels:
label = labels[self]
elif self.name in labels:
label = labels[self.name]
elif self.value in labels:
label = labels[self.value]
else:
label = self.name
setattr(self, '_label', label)
return label
def get_labels(self):
return None
class BehaviourType(Enum):
ATTRIBUTE = 'Attribute'
SEGMENT = 'Segment'
METRIC = 'Metric'
class DisplayType(Enum):
TEXT = 1
INTEGER = 2
DOUBLE = 3
PERCENT = 4
MONEY = 5
DATE = 6
DATETIME = 7
LIST = 8
OBJECT = 9
ENUM = 10
BOOL = 11
EMPTY_VALUES = {
DisplayType.TEXT: '',
DisplayType.INTEGER: 0,
DisplayType.DOUBLE: 0.0,
DisplayType.PERCENT: 0.0,
DisplayType.MONEY: 0,
DisplayType.DATE: '20001023',
DisplayType.DATETIME: '20001023 000000',
DisplayType.LIST: '[]',
DisplayType.OBJECT: '',
DisplayType.ENUM: '',
DisplayType.BOOL: False,
}
class Order(Enum):
ASC = 1
DESC = 2
OrderBy = namedtuple('OrderBy', ('column', 'order'))
[docs]
class JoinType(Enum):
INNER = 'inner'
"""Inner join."""
OUTER = 'outer'
"""Outer join."""
LEFT = 'left'
"""Left join."""
RIGHT = 'right'
"""Right join."""
[docs]
class HorizontalAlign(Enum):
LEFT = 'left'
"""Text align left."""
RIGHT = 'right'
"""Text align right."""
CENTER = 'center'
"""Text align center."""
[docs]
class VerticalAlign(Enum):
TOP = 'top'
"""Text align top."""
MIDDLE = 'middle'
"""Text align middle."""
BOTTOM = 'bottom'
"""Text align bottom."""
class WrapStrategy(Enum):
WRAP = 'wrap'
""" Wrap words longer than a line. """
[docs]
class Color:
"""
Color representation. Can be given either in RGB with individual components::
>>> Color(139, 188, 47)
Or as a single hex number with all components::
>>> Color(0xF6A413)
"""
def __init__(self, red: float, green: float | None = None, blue: float | None = None, alpha: float | None = None):
"""
:param red: Red component or hexadecimal , 0 - 255
:param green: Green component, 0 - 255
:param blue: Blue component, 0 - 255
:param alpha: Alpha component, always 0 - 1.
"""
if green is not None and blue is not None:
self.red = red
self.green = green
self.blue = blue
else:
self.red = red >> 16
self.green = (red & 0xFF00) >> 8
self.blue = red & 0xFF
self.alpha = alpha
[docs]
@classmethod
def from_hex(cls, text: str) -> Color:
"""
Creates a color from string with hexadecimal representation of the color.
:param text: Color as ``RRGGBB`` or ``#RRGGBB`` hex string.
:return: A ``Color`` instance.
"""
if text.startswith('#') and len(text) == 7:
return cls(int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16))
elif len(text) == 6:
return cls(int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
else:
raise ValueError('Invalid input format, expected hex color optionally prefixed with #.')
def to_dict(self):
result = {'red': self.red, 'green': self.green, 'blue': self.blue}
if self.alpha is not None:
result['alpha'] = self.alpha
return result
def to_normalized_dict(self):
result = {'red': self.red / 255, 'green': self.green / 255, 'blue': self.blue / 255}
if self.alpha is not None:
result['alpha'] = self.alpha
return result
def to_hex_str(self):
return f'#{self.red:02X}{self.green:02X}{self.blue:02X}'
[docs]
def change_luminosity(self, by: float):
"""
Darkens the given color by specified value. The color is converted
:param by: Darken by, should be a float between -1 and 1. If you darken any color by 1, it will become black, if
you darken
:return: Darkened color.
"""
norm_r = self.red / 255
norm_g = self.green / 255
norm_b = self.blue / 255
h, l, s = colorsys.rgb_to_hls(norm_r, norm_g, norm_b)
new_l = min(1, max(0, l + by))
new_norm_r, new_norm_g, new_norm_b = colorsys.hls_to_rgb(h, new_l, s)
return Color(int(new_norm_r * 255), int(new_norm_g * 255), int(new_norm_b * 255))
[docs]
def get_text_color(self, keep_alpha: bool = True):
"""
Gets a color that will provide enough contrast when placed on background filled with this color.
Currently returns either black or white. The decision is based on the luma channel from the color in YIQ
color space. The alpha channel is not taken into account during the calculation, but the new color can have
the same alpha value as the background one.
The method is described in the following article:
https://24ways.org/2010/calculating-color-contrast/
:param keep_alpha: Whether the text color should have the same value for alpha channel as the background.
:return: Color for the text.
"""
if keep_alpha:
alpha = self.alpha
else:
alpha = None
luma_value = ((self.red * 299) + (self.green * 587) + (self.blue * 114)) / 1000
return Color(0, 0, 0, alpha) if luma_value >= 128 else Color(255, 255, 255, alpha)
def __hash__(self):
return hash((self.red, self.green, self.blue, self.alpha))
def __eq__(self, other):
if isinstance(other, Color):
return (
self.red == other.red
and self.green == other.green
and self.blue == other.blue
and self.alpha == other.alpha
)
else:
return False
def __repr__(self):
return f'Color(red={self.red}, green={self.green}, blue={self.blue}, alpha={self.alpha})'
[docs]
class TextFormat:
"""
Represents a text format.
"""
BOLD = None # type: TextFormat
"""Predefined format for bold text."""
ITALIC = None # type: TextFormat
"""Predefined format for italics."""
def __init__(
self,
foreground_color: Color | None = None,
size: int | None = None,
bold: bool | None = None,
italic: bool | None = None,
):
"""
:param foreground_color: Foreground color.
:param size: Font size.
:param bold: Bold?
:param italic: Italics?
"""
self.foreground_color = foreground_color
self.size = size
self.bold = bold
self.italic = italic
@property
def empty(self):
return self.foreground_color is None and self.size is None and self.bold is None and self.italic is None
def to_dict(self):
result = {}
if self.foreground_color is not None:
result['foregroundColor'] = self.foreground_color.to_normalized_dict()
if self.size is not None:
result['fontSize'] = self.size
if self.bold is not None:
result['bold'] = self.bold
if self.italic is not None:
result['italic'] = self.italic
return result
def __hash__(self):
return hash((self.foreground_color, self.size, self.bold, self.italic))
def __eq__(self, other):
if isinstance(other, TextFormat):
return (
self.foreground_color == other.foreground_color
and self.size == other.size
and self.bold == other.bold
and self.italic == other.italic
)
else:
return False
TextFormat.BOLD = TextFormat(bold=True)
TextFormat.ITALIC = TextFormat(italic=True)
[docs]
class CellStyle:
"""
Represents a cell style -- contains background color, text format and alignment.
"""
def __init__(
self,
background_color: Color | None = None,
text_format: TextFormat | None = None,
horizontal_alignment: HorizontalAlign | None = None,
vertical_alignment: VerticalAlign | None = None,
wrap_strategy: WrapStrategy | None = None,
):
"""
:param background_color: Background color.
:param text_format: Text format.
:param horizontal_alignment: Horizontal text alignment.
:param vertical_alignment: Vertical text alignment.
:param wrap_strategy: Text wrapping.
"""
self.background_color = background_color
self.text_format = text_format
self.horizontal_alignment = horizontal_alignment
self.vertical_alignment = vertical_alignment
self.wrap_strategy = wrap_strategy
@property
def empty(self):
return (
self.background_color is None
and self.horizontal_alignment is None
and self.vertical_alignment is None
and (self.text_format is None or self.text_format.empty)
)
def to_dict(self):
result = {}
if self.background_color is not None:
result['backgroundColor'] = self.background_color.to_normalized_dict()
if self.horizontal_alignment is not None:
result['horizontalAlignment'] = self.horizontal_alignment.value.upper()
if self.vertical_alignment is not None:
result['verticalAlignment'] = self.vertical_alignment.value.upper()
if self.text_format is not None and not self.text_format.empty:
result['textFormat'] = self.text_format.to_dict()
if self.wrap_strategy is not None:
result['wrapStrategy'] = self.wrap_strategy.value.upper()
return result
def __hash__(self):
return hash((self.background_color, self.text_format, self.horizontal_alignment, self.vertical_alignment))
def __eq__(self, other):
if isinstance(other, CellStyle):
return (
self.background_color == other.background_color
and self.text_format == other.text_format
and self.horizontal_alignment == other.horizontal_alignment
and self.vertical_alignment == other.vertical_alignment
)
else:
return False
DEFAULT_HEADER_STYLE = CellStyle(
Color(0xDD, 0xDD, 0xDD), TextFormat(bold=True), HorizontalAlign.LEFT, VerticalAlign.MIDDLE
)
DEFAULT_GROUP_STYLE = CellStyle(
Color(0xDD, 0xDD, 0xDD), TextFormat(bold=True), HorizontalAlign.CENTER, VerticalAlign.MIDDLE
)
def make_header_style(color, wrap_strategy: WrapStrategy | None = None):
if color:
text_color = color.get_text_color()
else:
text_color = None
return CellStyle(
background_color=color,
text_format=TextFormat(foreground_color=text_color, bold=True),
horizontal_alignment=HorizontalAlign.CENTER,
vertical_alignment=VerticalAlign.MIDDLE,
wrap_strategy=wrap_strategy,
)
def format_enum(item: Enum | None, *args, **kwargs):
if isinstance(item, LabeledEnum):
return item.label
elif isinstance(item, Enum):
return item.value
else:
return ''
def format_date(value, *args):
if isinstance(value, pandas.Timestamp):
return value.to_pydatetime().date()
else:
return value
def format_datetime(value, *args):
if isinstance(value, pandas.Timestamp):
return value.to_pydatetime()
else:
return value
def format_object(obj, *_):
from ppc_robot_lib.utils import json
if obj is None:
return ''
return json.get_encoder().encode(obj)
TYPE_FORMATS: dict[DisplayType, tuple[Format, Callable[[Any, Any], Any]]] = {
DisplayType.TEXT: (Format(FormatType.TEXT), None),
DisplayType.INTEGER: (Format(FormatType.NUMBER), None),
DisplayType.DOUBLE: (Format(FormatType.NUMBER, '0.0#'), None),
DisplayType.PERCENT: (Format(FormatType.PERCENT, '0.00%'), None),
DisplayType.MONEY: (Format(FormatType.CURRENCY), None),
DisplayType.DATE: (Format(FormatType.DATE), format_date),
DisplayType.DATETIME: (Format(FormatType.DATE_TIME), format_datetime),
DisplayType.LIST: (Format(FormatType.TEXT), lambda x, _: '; '.join(map(str, x)) if x is not None else ''),
DisplayType.OBJECT: (Format(FormatType.TEXT), format_object),
DisplayType.ENUM: (Format(FormatType.TEXT), format_enum),
DisplayType.BOOL: (Format(FormatType.NUMBER, '[=0]"No";[=1]"Yes"'), lambda x, _: 1 if x else 0),
}
ColumnLabelType = str | bytes | Enum | int | float | complex | bool | None
[docs]
class Column:
"""
Column definition. Contains mapping to columns in the output table (``name``) and defines header and value format.
"""
def __init__(
self,
title: str,
name: ColumnLabelType | tuple[ColumnLabelType, ...],
style: CellStyle | None = None,
cell_format: Format | None = None,
formatter: Callable[[Any, pandas.Series], Any] | None = None,
format_rules: list[FormatRule] | None = None,
display_type: DisplayType = None,
width: int = None,
max_width: int = None,
horizontal_alignment: HorizontalAlign | None = None,
vertical_alignment: VerticalAlign | None = VerticalAlign.TOP,
):
"""
:param title: Header title.
:param name: Column name to use.
:param style: Header style.
:param cell_format: Value format.
:param formatter: Custom format function.
:param format_rules: Conditional formatting rules.
:param width: Set specific width of the column. The width should be set as a number of characters that should
fit into the column.
:param max_width: Maximum width of the column. The width should be set as a number of characters that should
fit into the column. If the column is narrower, it is not resized and automatic resizing takes effect.
If the width parameter is set, it takes precedence over ``max_width``.
:param horizontal_alignment: Horizontal alignment of values.
"""
self.title = title
self.name = name
self.style = style if style is not None else DEFAULT_HEADER_STYLE
if display_type is not None:
self.cell_format, self.formatter = TYPE_FORMATS[display_type]
else:
self.cell_format = cell_format
self.formatter = formatter # type: Callable[[Any], Any] | None
self.format_rules = format_rules if format_rules is not None else [] # type: list[FormatRule] | None
self.width = width
self.max_width = max_width
self.horizontal_alignment = horizontal_alignment
self.vertical_alignment = vertical_alignment
def __eq__(self, other):
if isinstance(other, Column):
return (
self.title == other.title
and self.name == other.name
and self.style == other.style
and self.cell_format == other.cell_format
and self.formatter == other.formatter
and self.format_rules == other.format_rules
)
else:
return False
[docs]
class ColumnGroup:
"""
Column group.
"""
def __init__(self, title: str, columns: list[ColumnGroup | Column], style: CellStyle | None = None):
"""
:param title: Group title.
:param columns: List of columns or nested column groups (can be mixed).
:param style: Header style.
"""
self.title = title
self.columns = columns
self.style = style if style is not None else DEFAULT_GROUP_STYLE
# Derived properties.
self.flat_columns = []
self.width = 0
self.depth = 0
self._analyze()
def _analyze(self):
for column in self.columns:
if isinstance(column, Column):
width = 1
depth = 2
self.flat_columns.append(column)
elif isinstance(column, ColumnGroup):
width = column.width
depth = column.depth + 1
self.flat_columns.extend(column.flat_columns)
else:
raise ValueError('Only Column and ColumnGroup instances are allowed as a column of ColumnGroup')
self.width += width
if depth > self.depth:
self.depth = depth
[docs]
class BoolConditionOperator(Enum):
"""
Operators for the :py:class:`BoolConditionRule`.
"""
EQ = 'eq'
"""Value equals the reference value."""
NEQ = 'neq'
"""Value does not equal the reference value."""
LT = 'lt'
"""Value is less than the reference value."""
LTE = 'lte'
"""Value is less than or equal to the reference value."""
GT = 'gt'
"""Value is greater than the reference value."""
GTE = 'gte'
"""Value is greater than or equal to the reference value."""
BETWEEN = 'between'
"""Value is in the defined range. The reference value must be given as a pair of (a, b)."""
NOT_BETWEEN = 'not_between'
"""Value is not in the defined range. The reference value must be given as a pair of (a, b)."""
CUSTOM_FORMULA = 'custom_formula'
"""Custom formula evaluates to TRUE. The formula should start with the `=` sign."""
[docs]
class BoolConditionRule(FormatRule):
"""
Boolean conditional rule. If the value matches the given condition, apply the given style.
"""
def __init__(self, op: BoolConditionOperator, value: float | list[float] | tuple[float] | str, style: CellStyle):
"""
:param op: Operator.
:param value: Reference value.
:param style: Style to apply if the condition matches.
"""
self.op = op
self.value = value
self.style = style
[docs]
class GradientPointType(Enum):
"""
Point type in the gradient for :py:class:`GradientRule`.
"""
MIN = 'min'
"""Determine the minimum value."""
MAX = 'max'
"""Determine the maximum value."""
NUMBER = 'number'
"""Compare to the given number."""
PERCENT = 'percent'
"""Set point at the given percent."""
PERCENTILE = 'percentile'
"""Set point at the given percentile."""
[docs]
class GradientPoint:
"""
Point in the gradient for :py:class:`GradientRule`.
"""
def __init__(self, value_type: GradientPointType, value: float | None, color: Color):
"""
:param value_type: Type of the point (reference value).
:param value: Reference value. Not used for MIN and MAX point types.
:param color: Color to apply.
"""
self.value_type = value_type
self.value = value
self.color = color
[docs]
class GradientRule(FormatRule):
"""
Gradient conditional format rule. The color of a cell is determined by its position on the scale defined by
``point_min``, ``point_mid`` and ``point_max``.
"""
def __init__(self, point_min: GradientPoint, point_mid: GradientPoint, point_max: GradientPoint):
"""
:param point_min: Minumum point.
:param point_mid: Middle point.
:param point_max: Maximum point.
"""
self.point_min = point_min
self.point_mid = point_mid
self.point_max = point_max
def create_format_rule(rule_def: dict[str, Any]) -> FormatRule:
"""
Creates a conditional format rule. Input should be a dictionary with ``type`` key and values for the given rule.
Currently only boolean (format is applied if the given condition is met) and gradient (the cells have a background
according to values of the scale) rules are supported.
Example of the boolean rule::
>>> {
... 'type': 'boolean',
... 'op': 'lte',
... 'value': 10,
... 'bg_color': '#FF9999',
... 'text': {
... 'bold': True,
... },
... }
See :py:class:`BoolConditionOperator` enumeration for list of available operators.
Gradient rule is defined by 3 points on the scale - minimal value, middle value and maximum::
>>> {
... 'type': 'gradient',
... 'min': {
... 'type': 'number',
... 'value': '0',
... 'color': '#FFE8E0',
... },
... 'mid': {
... 'type': 'number',
... 'value': '5',
... 'color': '#FFF7E3',
... },
... 'max': {
... 'type': 'number',
... 'value': '10',
... 'color': '#EEFFE3',
... },
... }
``type`` key can have any value from the :py:class:`GradientPointType` enum. The gradient rules are modeled after
gradients in Google Sheets, so for more detailed explanation, see the official documentation of Google Sheets API:
https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#GradientRule
:param rule_def: Dictionary with rule definition passed from the user interface.
:return: Rule definition object that can be passed to the `TablesOutputStep`.
"""
rule = rule_def['rule']
if rule_def['type'] == 'boolean':
return BoolConditionRule(
BoolConditionOperator(rule['op']),
rule['value'],
CellStyle(
background_color=Color.from_hex(rule['bg_color']),
text_format=TextFormat(bold=rule['text']['bold']),
horizontal_alignment=None,
vertical_alignment=None,
),
)
elif rule_def['type'] == 'gradient':
def create_point(point):
return GradientPoint(GradientPointType(point['type']), point['value'], Color.from_hex(point['color']))
return GradientRule(create_point(rule['min']), create_point(rule['mid']), create_point(rule['max']))
class FormattedTextNode:
def __init__(self, text: str, text_format: TextFormat | None = None):
self.text = text
self.text_format = text_format
def __len__(self):
return len(self.text)
def __eq__(self, other):
if isinstance(other, FormattedTextNode):
return self.text == other.text and self.text_format == other.text_format
else:
return False
class FormattedText:
def __init__(self, *args: str | FormattedTextNode):
self._nodes = list(args)
@property
def fragments(self) -> list[str | FormattedTextNode]:
return self._nodes
def append(self, text_node: str | FormattedTextNode):
self._nodes.append(text_node)
def append_text(self, text: str, text_format: TextFormat | None = None):
if text_format is None:
self._nodes.append(text)
else:
self._nodes.append(FormattedTextNode(text, text_format))
def __len__(self):
return len(self._nodes)
def __iter__(self):
yield from self._nodes
def __str__(self):
return ''.join(node.text if isinstance(node, FormattedTextNode) else node for node in self._nodes)
class Image:
def __init__(self, url: str):
self.url = url
class TrendValue(Enum):
LOWER_IS_BETTER = 0
HIGHER_IS_BETTER = 1
class TrendOptions:
def __init__(self, better_value: TrendValue = TrendValue.HIGHER_IS_BETTER):
"""
Options for computing and formatting trend fields.
:param better_value: Specifies which values are considered better.
"""
self.better_value = better_value
class SparklineType(Enum):
"""
Sparkline type.
"""
LINE = 'line'
COLUMN = 'column'
class SparklineInvalidValueHandling:
def __init__(self, trim_left=True, replace=0):
self.trim_left = trim_left
self.replace = replace
class SparklineOptions:
def __init__(
self,
type_: SparklineType = SparklineType.LINE,
y_min=None,
y_max=None,
line_width=None,
color: Color | None = None,
negative_color: Color | None = None,
low_color: Color | None = None,
high_color: Color | None = None,
nan_handling: SparklineInvalidValueHandling = None,
inf_handling: SparklineInvalidValueHandling = None,
use_trend_color: bool = False,
):
"""
Options for the sparklines. If any of the options is not given, defaults of the given output format is
used. This can be different between Google Sheets, Excel etc.
:param type_: Sparkline type. Defaults to LINE.
:param y_min: Y-axis minimal value. Usually defaults to the min value from data.
:param y_max: Y-axis maximum value. Usually defaults to the max value from data.
:param line_width: Line width (used for LINE sparkline).
:param color: Color of line or columns (COLUMN only).
:param negative_color: Color of negative columns (COLUMN only).
:param low_color: Color of columns with min value (COLUMN only).
:param high_color: Color of columns with max value (COLUMN only).
"""
self.type = type_
self.y_min = y_min
self.y_max = y_max
self.line_width = line_width
self.color = color
self.negative_color = negative_color
self.low_color = low_color
self.high_color = high_color
self.nan_handling = nan_handling if nan_handling is not None else SparklineInvalidValueHandling()
self.inf_handling = inf_handling if inf_handling is not None else SparklineInvalidValueHandling()
self.use_trend_color = use_trend_color
def __repr__(self):
return f'SparklineOptions(type_={repr(self.type)}, ...)'
class Sparkline:
def __init__(self, values: list[float], options: SparklineOptions, color_override: Color = None):
"""
Represents a sparkline.
:param values: Sparkline values, from oldest to newest.
:param options: Options.
"""
self.values = self._normalize_values(values, options.nan_handling, options.inf_handling)
self.options = options
self.color_override = color_override
@classmethod
def _normalize_values(
cls,
values: list[float | int],
nan_handling: SparklineInvalidValueHandling,
inf_handling: SparklineInvalidValueHandling,
):
result = []
if values is not None and isinstance(values, Iterable) and not isinstance(values, str):
had_valid_value = False
# Sparklines requires at least two values, so set the maximum index that can be removed:
index_to_keep = len(values) - 2
for index, value in enumerate(values):
if value is None or pandas.isnull(value) or isnan(value):
if not had_valid_value and nan_handling.trim_left and index < index_to_keep:
continue
value = nan_handling.replace
elif isinf(value):
if not had_valid_value and inf_handling.trim_left and index < index_to_keep:
continue
value = inf_handling.replace * (1 if value >= 0 else -1)
had_valid_value = True
result.append(value)
return result
def __repr__(self):
return f'Sparkline(data={repr(self.values)}, options={repr(self.options)})'
@dataclass
class TaskDescription:
title: str
short_description: str = None
description: str = None
image_url: str = None
video_url: str = None