Source code for ppc_robot_lib.utils.types

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, )
[docs] class FormatType(Enum): TEXT = 'text' """Format as text.""" NUMBER = 'number' """Format as number.""" PERCENT = 'percent' """Format as percents. The value shall be given as double divided by a 100, i.e. 0.1 means 10%.""" CURRENCY = 'currency' """Currency.""" DATE = 'date' """Date.""" TIME = 'time' """Time without date.""" DATE_TIME = 'date_time' """Date and time.""" SCIENTIFIC = 'scientific' """Scientific number format, e.g. 10e3."""
[docs] class Format: """ Defines how to format values in the cell. """ def __init__(self, format_type: FormatType, pattern: str | None = None): """ :param format_type: Format type. :param pattern: Optional pattern, see `Google Sheets Date and Number Formats <https://developers.google.com/sheets/api/guides/formats>`_. The pattern might be replaced with another mechanism if we find that formats are not portable between different output services. """ self.format_type = format_type self.pattern = pattern def to_dict(self): result = {'type': self.format_type.value.upper()} if self.pattern is not None: result['pattern'] = self.pattern return result def __eq__(self, other): if isinstance(other, Format): return self.format_type == other.format_type and self.pattern == other.pattern else: return False def __hash__(self): return hash((self.format_type, self.pattern))
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 FormatRule: """ Abstract class for conditional format rules. Use one of: * :py:class:`BoolConditionRule`, or * :py:class:`GradientRule`. """ pass
[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