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