1""" 
    2Tool for creating styles from a dictionary. 
    3""" 
    4 
    5from __future__ import annotations 
    6 
    7import itertools 
    8import re 
    9from enum import Enum 
    10from typing import Hashable, TypeVar 
    11 
    12from prompt_toolkit.cache import SimpleCache 
    13 
    14from .base import ( 
    15    ANSI_COLOR_NAMES, 
    16    ANSI_COLOR_NAMES_ALIASES, 
    17    DEFAULT_ATTRS, 
    18    Attrs, 
    19    BaseStyle, 
    20) 
    21from .named_colors import NAMED_COLORS 
    22 
    23__all__ = [ 
    24    "Style", 
    25    "parse_color", 
    26    "Priority", 
    27    "merge_styles", 
    28] 
    29 
    30_named_colors_lowercase = {k.lower(): v.lstrip("#") for k, v in NAMED_COLORS.items()} 
    31 
    32 
    33def parse_color(text: str) -> str: 
    34    """ 
    35    Parse/validate color format. 
    36 
    37    Like in Pygments, but also support the ANSI color names. 
    38    (These will map to the colors of the 16 color palette.) 
    39    """ 
    40    # ANSI color names. 
    41    if text in ANSI_COLOR_NAMES: 
    42        return text 
    43    if text in ANSI_COLOR_NAMES_ALIASES: 
    44        return ANSI_COLOR_NAMES_ALIASES[text] 
    45 
    46    # 140 named colors. 
    47    try: 
    48        # Replace by 'hex' value. 
    49        return _named_colors_lowercase[text.lower()] 
    50    except KeyError: 
    51        pass 
    52 
    53    # Hex codes. 
    54    if text[0:1] == "#": 
    55        col = text[1:] 
    56 
    57        # Keep this for backwards-compatibility (Pygments does it). 
    58        # I don't like the '#' prefix for named colors. 
    59        if col in ANSI_COLOR_NAMES: 
    60            return col 
    61        elif col in ANSI_COLOR_NAMES_ALIASES: 
    62            return ANSI_COLOR_NAMES_ALIASES[col] 
    63 
    64        # 6 digit hex color. 
    65        elif len(col) == 6: 
    66            return col 
    67 
    68        # 3 digit hex color. 
    69        elif len(col) == 3: 
    70            return col[0] * 2 + col[1] * 2 + col[2] * 2 
    71 
    72    # Default. 
    73    elif text in ("", "default"): 
    74        return text 
    75 
    76    raise ValueError(f"Wrong color format {text!r}") 
    77 
    78 
    79# Attributes, when they are not filled in by a style. None means that we take 
    80# the value from the parent. 
    81_EMPTY_ATTRS = Attrs( 
    82    color=None, 
    83    bgcolor=None, 
    84    bold=None, 
    85    underline=None, 
    86    strike=None, 
    87    italic=None, 
    88    blink=None, 
    89    reverse=None, 
    90    hidden=None, 
    91    dim=None, 
    92) 
    93 
    94 
    95def _expand_classname(classname: str) -> list[str]: 
    96    """ 
    97    Split a single class name at the `.` operator, and build a list of classes. 
    98 
    99    E.g. 'a.b.c' becomes ['a', 'a.b', 'a.b.c'] 
    100    """ 
    101    result = [] 
    102    parts = classname.split(".") 
    103 
    104    for i in range(1, len(parts) + 1): 
    105        result.append(".".join(parts[:i]).lower()) 
    106 
    107    return result 
    108 
    109 
    110def _parse_style_str(style_str: str) -> Attrs: 
    111    """ 
    112    Take a style string, e.g.  'bg:red #88ff00 class:title' 
    113    and return a `Attrs` instance. 
    114    """ 
    115    # Start from default Attrs. 
    116    if "noinherit" in style_str: 
    117        attrs = DEFAULT_ATTRS 
    118    else: 
    119        attrs = _EMPTY_ATTRS 
    120 
    121    # Now update with the given attributes. 
    122    for part in style_str.split(): 
    123        if part == "noinherit": 
    124            pass 
    125        elif part == "bold": 
    126            attrs = attrs._replace(bold=True) 
    127        elif part == "nobold": 
    128            attrs = attrs._replace(bold=False) 
    129        elif part == "italic": 
    130            attrs = attrs._replace(italic=True) 
    131        elif part == "noitalic": 
    132            attrs = attrs._replace(italic=False) 
    133        elif part == "underline": 
    134            attrs = attrs._replace(underline=True) 
    135        elif part == "nounderline": 
    136            attrs = attrs._replace(underline=False) 
    137        elif part == "strike": 
    138            attrs = attrs._replace(strike=True) 
    139        elif part == "nostrike": 
    140            attrs = attrs._replace(strike=False) 
    141 
    142        # prompt_toolkit extensions. Not in Pygments. 
    143        elif part == "blink": 
    144            attrs = attrs._replace(blink=True) 
    145        elif part == "noblink": 
    146            attrs = attrs._replace(blink=False) 
    147        elif part == "reverse": 
    148            attrs = attrs._replace(reverse=True) 
    149        elif part == "noreverse": 
    150            attrs = attrs._replace(reverse=False) 
    151        elif part == "hidden": 
    152            attrs = attrs._replace(hidden=True) 
    153        elif part == "nohidden": 
    154            attrs = attrs._replace(hidden=False) 
    155        elif part == "dim": 
    156            attrs = attrs._replace(dim=True) 
    157        elif part == "nodim": 
    158            attrs = attrs._replace(dim=False) 
    159 
    160        # Pygments properties that we ignore. 
    161        elif part in ("roman", "sans", "mono"): 
    162            pass 
    163        elif part.startswith("border:"): 
    164            pass 
    165 
    166        # Ignore pieces in between square brackets. This is internal stuff. 
    167        # Like '[transparent]' or '[set-cursor-position]'. 
    168        elif part.startswith("[") and part.endswith("]"): 
    169            pass 
    170 
    171        # Colors. 
    172        elif part.startswith("bg:"): 
    173            attrs = attrs._replace(bgcolor=parse_color(part[3:])) 
    174        elif part.startswith("fg:"):  # The 'fg:' prefix is optional. 
    175            attrs = attrs._replace(color=parse_color(part[3:])) 
    176        else: 
    177            attrs = attrs._replace(color=parse_color(part)) 
    178 
    179    return attrs 
    180 
    181 
    182CLASS_NAMES_RE = re.compile(r"^[a-z0-9.\s_-]*$")  # This one can't contain a comma! 
    183 
    184 
    185class Priority(Enum): 
    186    """ 
    187    The priority of the rules, when a style is created from a dictionary. 
    188 
    189    In a `Style`, rules that are defined later will always override previous 
    190    defined rules, however in a dictionary, the key order was arbitrary before 
    191    Python 3.6. This means that the style could change at random between rules. 
    192 
    193    We have two options: 
    194 
    195    - `DICT_KEY_ORDER`: This means, iterate through the dictionary, and take 
    196       the key/value pairs in order as they come. This is a good option if you 
    197       have Python >3.6. Rules at the end will override rules at the beginning. 
    198    - `MOST_PRECISE`: keys that are defined with most precision will get higher 
    199      priority. (More precise means: more elements.) 
    200    """ 
    201 
    202    DICT_KEY_ORDER = "KEY_ORDER" 
    203    MOST_PRECISE = "MOST_PRECISE" 
    204 
    205 
    206# We don't support Python versions older than 3.6 anymore, so we can always 
    207# depend on dictionary ordering. This is the default. 
    208default_priority = Priority.DICT_KEY_ORDER 
    209 
    210 
    211class Style(BaseStyle): 
    212    """ 
    213    Create a ``Style`` instance from a list of style rules. 
    214 
    215    The `style_rules` is supposed to be a list of ('classnames', 'style') tuples. 
    216    The classnames are a whitespace separated string of class names and the 
    217    style string is just like a Pygments style definition, but with a few 
    218    additions: it supports 'reverse' and 'blink'. 
    219 
    220    Later rules always override previous rules. 
    221 
    222    Usage:: 
    223 
    224        Style([ 
    225            ('title', '#ff0000 bold underline'), 
    226            ('something-else', 'reverse'), 
    227            ('class1 class2', 'reverse'), 
    228        ]) 
    229 
    230    The ``from_dict`` classmethod is similar, but takes a dictionary as input. 
    231    """ 
    232 
    233    def __init__(self, style_rules: list[tuple[str, str]]) -> None: 
    234        class_names_and_attrs = [] 
    235 
    236        # Loop through the rules in the order they were defined. 
    237        # Rules that are defined later get priority. 
    238        for class_names, style_str in style_rules: 
    239            assert CLASS_NAMES_RE.match(class_names), repr(class_names) 
    240 
    241            # The order of the class names doesn't matter. 
    242            # (But the order of rules does matter.) 
    243            class_names_set = frozenset(class_names.lower().split()) 
    244            attrs = _parse_style_str(style_str) 
    245 
    246            class_names_and_attrs.append((class_names_set, attrs)) 
    247 
    248        self._style_rules = style_rules 
    249        self.class_names_and_attrs = class_names_and_attrs 
    250 
    251    @property 
    252    def style_rules(self) -> list[tuple[str, str]]: 
    253        return self._style_rules 
    254 
    255    @classmethod 
    256    def from_dict( 
    257        cls, style_dict: dict[str, str], priority: Priority = default_priority 
    258    ) -> Style: 
    259        """ 
    260        :param style_dict: Style dictionary. 
    261        :param priority: `Priority` value. 
    262        """ 
    263        if priority == Priority.MOST_PRECISE: 
    264 
    265            def key(item: tuple[str, str]) -> int: 
    266                # Split on '.' and whitespace. Count elements. 
    267                return sum(len(i.split(".")) for i in item[0].split()) 
    268 
    269            return cls(sorted(style_dict.items(), key=key)) 
    270        else: 
    271            return cls(list(style_dict.items())) 
    272 
    273    def get_attrs_for_style_str( 
    274        self, style_str: str, default: Attrs = DEFAULT_ATTRS 
    275    ) -> Attrs: 
    276        """ 
    277        Get `Attrs` for the given style string. 
    278        """ 
    279        list_of_attrs = [default] 
    280        class_names: set[str] = set() 
    281 
    282        # Apply default styling. 
    283        for names, attr in self.class_names_and_attrs: 
    284            if not names: 
    285                list_of_attrs.append(attr) 
    286 
    287        # Go from left to right through the style string. Things on the right 
    288        # take precedence. 
    289        for part in style_str.split(): 
    290            # This part represents a class. 
    291            # Do lookup of this class name in the style definition, as well 
    292            # as all class combinations that we have so far. 
    293            if part.startswith("class:"): 
    294                # Expand all class names (comma separated list). 
    295                new_class_names = [] 
    296                for p in part[6:].lower().split(","): 
    297                    new_class_names.extend(_expand_classname(p)) 
    298 
    299                for new_name in new_class_names: 
    300                    # Build a set of all possible class combinations to be applied. 
    301                    combos = set() 
    302                    combos.add(frozenset([new_name])) 
    303 
    304                    for count in range(1, len(class_names) + 1): 
    305                        for c2 in itertools.combinations(class_names, count): 
    306                            combos.add(frozenset(c2 + (new_name,))) 
    307 
    308                    # Apply the styles that match these class names. 
    309                    for names, attr in self.class_names_and_attrs: 
    310                        if names in combos: 
    311                            list_of_attrs.append(attr) 
    312 
    313                    class_names.add(new_name) 
    314 
    315            # Process inline style. 
    316            else: 
    317                inline_attrs = _parse_style_str(part) 
    318                list_of_attrs.append(inline_attrs) 
    319 
    320        return _merge_attrs(list_of_attrs) 
    321 
    322    def invalidation_hash(self) -> Hashable: 
    323        return id(self.class_names_and_attrs) 
    324 
    325 
    326_T = TypeVar("_T") 
    327 
    328 
    329def _merge_attrs(list_of_attrs: list[Attrs]) -> Attrs: 
    330    """ 
    331    Take a list of :class:`.Attrs` instances and merge them into one. 
    332    Every `Attr` in the list can override the styling of the previous one. So, 
    333    the last one has highest priority. 
    334    """ 
    335 
    336    def _or(*values: _T) -> _T: 
    337        "Take first not-None value, starting at the end." 
    338        for v in values[::-1]: 
    339            if v is not None: 
    340                return v 
    341        raise ValueError  # Should not happen, there's always one non-null value. 
    342 
    343    return Attrs( 
    344        color=_or("", *[a.color for a in list_of_attrs]), 
    345        bgcolor=_or("", *[a.bgcolor for a in list_of_attrs]), 
    346        bold=_or(False, *[a.bold for a in list_of_attrs]), 
    347        underline=_or(False, *[a.underline for a in list_of_attrs]), 
    348        strike=_or(False, *[a.strike for a in list_of_attrs]), 
    349        italic=_or(False, *[a.italic for a in list_of_attrs]), 
    350        blink=_or(False, *[a.blink for a in list_of_attrs]), 
    351        reverse=_or(False, *[a.reverse for a in list_of_attrs]), 
    352        hidden=_or(False, *[a.hidden for a in list_of_attrs]), 
    353        dim=_or(False, *[a.dim for a in list_of_attrs]), 
    354    ) 
    355 
    356 
    357def merge_styles(styles: list[BaseStyle]) -> _MergedStyle: 
    358    """ 
    359    Merge multiple `Style` objects. 
    360    """ 
    361    styles = [s for s in styles if s is not None] 
    362    return _MergedStyle(styles) 
    363 
    364 
    365class _MergedStyle(BaseStyle): 
    366    """ 
    367    Merge multiple `Style` objects into one. 
    368    This is supposed to ensure consistency: if any of the given styles changes, 
    369    then this style will be updated. 
    370    """ 
    371 
    372    # NOTE: previously, we used an algorithm where we did not generate the 
    373    #       combined style. Instead this was a proxy that called one style 
    374    #       after the other, passing the outcome of the previous style as the 
    375    #       default for the next one. This did not work, because that way, the 
    376    #       priorities like described in the `Style` class don't work. 
    377    #       'class:aborted' was for instance never displayed in gray, because 
    378    #       the next style specified a default color for any text. (The 
    379    #       explicit styling of class:aborted should have taken priority, 
    380    #       because it was more precise.) 
    381    def __init__(self, styles: list[BaseStyle]) -> None: 
    382        self.styles = styles 
    383        self._style: SimpleCache[Hashable, Style] = SimpleCache(maxsize=1) 
    384 
    385    @property 
    386    def _merged_style(self) -> Style: 
    387        "The `Style` object that has the other styles merged together." 
    388 
    389        def get() -> Style: 
    390            return Style(self.style_rules) 
    391 
    392        return self._style.get(self.invalidation_hash(), get) 
    393 
    394    @property 
    395    def style_rules(self) -> list[tuple[str, str]]: 
    396        style_rules = [] 
    397        for s in self.styles: 
    398            style_rules.extend(s.style_rules) 
    399        return style_rules 
    400 
    401    def get_attrs_for_style_str( 
    402        self, style_str: str, default: Attrs = DEFAULT_ATTRS 
    403    ) -> Attrs: 
    404        return self._merged_style.get_attrs_for_style_str(style_str, default) 
    405 
    406    def invalidation_hash(self) -> Hashable: 
    407        return tuple(s.invalidation_hash() for s in self.styles)