Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/prompt_toolkit/styles/style.py: 23%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

173 statements  

1""" 

2Tool for creating styles from a dictionary. 

3""" 

4 

5from __future__ import annotations 

6 

7import itertools 

8import re 

9from collections.abc import Hashable 

10from enum import Enum 

11from typing import TypeVar 

12 

13from prompt_toolkit.cache import SimpleCache 

14 

15from .base import ( 

16 ANSI_COLOR_NAMES, 

17 ANSI_COLOR_NAMES_ALIASES, 

18 DEFAULT_ATTRS, 

19 Attrs, 

20 BaseStyle, 

21) 

22from .named_colors import NAMED_COLORS 

23 

24__all__ = [ 

25 "Style", 

26 "parse_color", 

27 "Priority", 

28 "merge_styles", 

29] 

30 

31_named_colors_lowercase = {k.lower(): v.lstrip("#") for k, v in NAMED_COLORS.items()} 

32 

33 

34def parse_color(text: str) -> str: 

35 """ 

36 Parse/validate color format. 

37 

38 Like in Pygments, but also support the ANSI color names. 

39 (These will map to the colors of the 16 color palette.) 

40 """ 

41 # ANSI color names. 

42 if text in ANSI_COLOR_NAMES: 

43 return text 

44 if text in ANSI_COLOR_NAMES_ALIASES: 

45 return ANSI_COLOR_NAMES_ALIASES[text] 

46 

47 # 140 named colors. 

48 try: 

49 # Replace by 'hex' value. 

50 return _named_colors_lowercase[text.lower()] 

51 except KeyError: 

52 pass 

53 

54 # Hex codes. 

55 if text[0:1] == "#": 

56 col = text[1:] 

57 

58 # Keep this for backwards-compatibility (Pygments does it). 

59 # I don't like the '#' prefix for named colors. 

60 if col in ANSI_COLOR_NAMES: 

61 return col 

62 elif col in ANSI_COLOR_NAMES_ALIASES: 

63 return ANSI_COLOR_NAMES_ALIASES[col] 

64 

65 # 6 digit hex color. 

66 elif len(col) == 6: 

67 return col 

68 

69 # 3 digit hex color. 

70 elif len(col) == 3: 

71 return col[0] * 2 + col[1] * 2 + col[2] * 2 

72 

73 # Default. 

74 elif text in ("", "default"): 

75 return text 

76 

77 raise ValueError(f"Wrong color format {text!r}") 

78 

79 

80# Attributes, when they are not filled in by a style. None means that we take 

81# the value from the parent. 

82_EMPTY_ATTRS = Attrs( 

83 color=None, 

84 bgcolor=None, 

85 bold=None, 

86 underline=None, 

87 strike=None, 

88 italic=None, 

89 blink=None, 

90 reverse=None, 

91 hidden=None, 

92 dim=None, 

93) 

94 

95 

96def _expand_classname(classname: str) -> list[str]: 

97 """ 

98 Split a single class name at the `.` operator, and build a list of classes. 

99 

100 E.g. 'a.b.c' becomes ['a', 'a.b', 'a.b.c'] 

101 """ 

102 result = [] 

103 parts = classname.split(".") 

104 

105 for i in range(1, len(parts) + 1): 

106 result.append(".".join(parts[:i]).lower()) 

107 

108 return result 

109 

110 

111def _parse_style_str(style_str: str) -> Attrs: 

112 """ 

113 Take a style string, e.g. 'bg:red #88ff00 class:title' 

114 and return a `Attrs` instance. 

115 """ 

116 # Start from default Attrs. 

117 if "noinherit" in style_str: 

118 attrs = DEFAULT_ATTRS 

119 else: 

120 attrs = _EMPTY_ATTRS 

121 

122 # Now update with the given attributes. 

123 for part in style_str.split(): 

124 if part == "noinherit": 

125 pass 

126 elif part == "bold": 

127 attrs = attrs._replace(bold=True) 

128 elif part == "nobold": 

129 attrs = attrs._replace(bold=False) 

130 elif part == "italic": 

131 attrs = attrs._replace(italic=True) 

132 elif part == "noitalic": 

133 attrs = attrs._replace(italic=False) 

134 elif part == "underline": 

135 attrs = attrs._replace(underline=True) 

136 elif part == "nounderline": 

137 attrs = attrs._replace(underline=False) 

138 elif part == "strike": 

139 attrs = attrs._replace(strike=True) 

140 elif part == "nostrike": 

141 attrs = attrs._replace(strike=False) 

142 

143 # prompt_toolkit extensions. Not in Pygments. 

144 elif part == "blink": 

145 attrs = attrs._replace(blink=True) 

146 elif part == "noblink": 

147 attrs = attrs._replace(blink=False) 

148 elif part == "reverse": 

149 attrs = attrs._replace(reverse=True) 

150 elif part == "noreverse": 

151 attrs = attrs._replace(reverse=False) 

152 elif part == "hidden": 

153 attrs = attrs._replace(hidden=True) 

154 elif part == "nohidden": 

155 attrs = attrs._replace(hidden=False) 

156 elif part == "dim": 

157 attrs = attrs._replace(dim=True) 

158 elif part == "nodim": 

159 attrs = attrs._replace(dim=False) 

160 

161 # Pygments properties that we ignore. 

162 elif part in ("roman", "sans", "mono"): 

163 pass 

164 elif part.startswith("border:"): 

165 pass 

166 

167 # Ignore pieces in between square brackets. This is internal stuff. 

168 # Like '[transparent]' or '[set-cursor-position]'. 

169 elif part.startswith("[") and part.endswith("]"): 

170 pass 

171 

172 # Colors. 

173 elif part.startswith("bg:"): 

174 attrs = attrs._replace(bgcolor=parse_color(part[3:])) 

175 elif part.startswith("fg:"): # The 'fg:' prefix is optional. 

176 attrs = attrs._replace(color=parse_color(part[3:])) 

177 else: 

178 attrs = attrs._replace(color=parse_color(part)) 

179 

180 return attrs 

181 

182 

183CLASS_NAMES_RE = re.compile(r"^[a-z0-9.\s_-]*$") # This one can't contain a comma! 

184 

185 

186class Priority(Enum): 

187 """ 

188 The priority of the rules, when a style is created from a dictionary. 

189 

190 In a `Style`, rules that are defined later will always override previous 

191 defined rules, however in a dictionary, the key order was arbitrary before 

192 Python 3.6. This means that the style could change at random between rules. 

193 

194 We have two options: 

195 

196 - `DICT_KEY_ORDER`: This means, iterate through the dictionary, and take 

197 the key/value pairs in order as they come. This is a good option if you 

198 have Python >3.6. Rules at the end will override rules at the beginning. 

199 - `MOST_PRECISE`: keys that are defined with most precision will get higher 

200 priority. (More precise means: more elements.) 

201 """ 

202 

203 DICT_KEY_ORDER = "KEY_ORDER" 

204 MOST_PRECISE = "MOST_PRECISE" 

205 

206 

207# We don't support Python versions older than 3.6 anymore, so we can always 

208# depend on dictionary ordering. This is the default. 

209default_priority = Priority.DICT_KEY_ORDER 

210 

211 

212class Style(BaseStyle): 

213 """ 

214 Create a ``Style`` instance from a list of style rules. 

215 

216 The `style_rules` is supposed to be a list of ('classnames', 'style') tuples. 

217 The classnames are a whitespace separated string of class names and the 

218 style string is just like a Pygments style definition, but with a few 

219 additions: it supports 'reverse' and 'blink'. 

220 

221 Later rules always override previous rules. 

222 

223 Usage:: 

224 

225 Style([ 

226 ('title', '#ff0000 bold underline'), 

227 ('something-else', 'reverse'), 

228 ('class1 class2', 'reverse'), 

229 ]) 

230 

231 The ``from_dict`` classmethod is similar, but takes a dictionary as input. 

232 """ 

233 

234 def __init__(self, style_rules: list[tuple[str, str]]) -> None: 

235 class_names_and_attrs = [] 

236 

237 # Loop through the rules in the order they were defined. 

238 # Rules that are defined later get priority. 

239 for class_names, style_str in style_rules: 

240 assert CLASS_NAMES_RE.match(class_names), repr(class_names) 

241 

242 # The order of the class names doesn't matter. 

243 # (But the order of rules does matter.) 

244 class_names_set = frozenset(class_names.lower().split()) 

245 attrs = _parse_style_str(style_str) 

246 

247 class_names_and_attrs.append((class_names_set, attrs)) 

248 

249 self._style_rules = style_rules 

250 self.class_names_and_attrs = class_names_and_attrs 

251 

252 @property 

253 def style_rules(self) -> list[tuple[str, str]]: 

254 return self._style_rules 

255 

256 @classmethod 

257 def from_dict( 

258 cls, style_dict: dict[str, str], priority: Priority = default_priority 

259 ) -> Style: 

260 """ 

261 :param style_dict: Style dictionary. 

262 :param priority: `Priority` value. 

263 """ 

264 if priority == Priority.MOST_PRECISE: 

265 

266 def key(item: tuple[str, str]) -> int: 

267 # Split on '.' and whitespace. Count elements. 

268 return sum(len(i.split(".")) for i in item[0].split()) 

269 

270 return cls(sorted(style_dict.items(), key=key)) 

271 else: 

272 return cls(list(style_dict.items())) 

273 

274 def get_attrs_for_style_str( 

275 self, style_str: str, default: Attrs = DEFAULT_ATTRS 

276 ) -> Attrs: 

277 """ 

278 Get `Attrs` for the given style string. 

279 """ 

280 list_of_attrs = [default] 

281 class_names: set[str] = set() 

282 

283 # Apply default styling. 

284 for names, attr in self.class_names_and_attrs: 

285 if not names: 

286 list_of_attrs.append(attr) 

287 

288 # Go from left to right through the style string. Things on the right 

289 # take precedence. 

290 for part in style_str.split(): 

291 # This part represents a class. 

292 # Do lookup of this class name in the style definition, as well 

293 # as all class combinations that we have so far. 

294 if part.startswith("class:"): 

295 # Expand all class names (comma separated list). 

296 new_class_names = [] 

297 for p in part[6:].lower().split(","): 

298 new_class_names.extend(_expand_classname(p)) 

299 

300 for new_name in new_class_names: 

301 # Build a set of all possible class combinations to be applied. 

302 combos = set() 

303 combos.add(frozenset([new_name])) 

304 

305 for count in range(1, len(class_names) + 1): 

306 for c2 in itertools.combinations(class_names, count): 

307 combos.add(frozenset(c2 + (new_name,))) 

308 

309 # Apply the styles that match these class names. 

310 for names, attr in self.class_names_and_attrs: 

311 if names in combos: 

312 list_of_attrs.append(attr) 

313 

314 class_names.add(new_name) 

315 

316 # Process inline style. 

317 else: 

318 inline_attrs = _parse_style_str(part) 

319 list_of_attrs.append(inline_attrs) 

320 

321 return _merge_attrs(list_of_attrs) 

322 

323 def invalidation_hash(self) -> Hashable: 

324 return id(self.class_names_and_attrs) 

325 

326 

327_T = TypeVar("_T") 

328 

329 

330def _merge_attrs(list_of_attrs: list[Attrs]) -> Attrs: 

331 """ 

332 Take a list of :class:`.Attrs` instances and merge them into one. 

333 Every `Attr` in the list can override the styling of the previous one. So, 

334 the last one has highest priority. 

335 """ 

336 

337 def _or(*values: _T) -> _T: 

338 "Take first not-None value, starting at the end." 

339 for v in values[::-1]: 

340 if v is not None: 

341 return v 

342 raise ValueError # Should not happen, there's always one non-null value. 

343 

344 return Attrs( 

345 color=_or("", *[a.color for a in list_of_attrs]), 

346 bgcolor=_or("", *[a.bgcolor for a in list_of_attrs]), 

347 bold=_or(False, *[a.bold for a in list_of_attrs]), 

348 underline=_or(False, *[a.underline for a in list_of_attrs]), 

349 strike=_or(False, *[a.strike for a in list_of_attrs]), 

350 italic=_or(False, *[a.italic for a in list_of_attrs]), 

351 blink=_or(False, *[a.blink for a in list_of_attrs]), 

352 reverse=_or(False, *[a.reverse for a in list_of_attrs]), 

353 hidden=_or(False, *[a.hidden for a in list_of_attrs]), 

354 dim=_or(False, *[a.dim for a in list_of_attrs]), 

355 ) 

356 

357 

358def merge_styles(styles: list[BaseStyle]) -> _MergedStyle: 

359 """ 

360 Merge multiple `Style` objects. 

361 """ 

362 styles = [s for s in styles if s is not None] 

363 return _MergedStyle(styles) 

364 

365 

366class _MergedStyle(BaseStyle): 

367 """ 

368 Merge multiple `Style` objects into one. 

369 This is supposed to ensure consistency: if any of the given styles changes, 

370 then this style will be updated. 

371 """ 

372 

373 # NOTE: previously, we used an algorithm where we did not generate the 

374 # combined style. Instead this was a proxy that called one style 

375 # after the other, passing the outcome of the previous style as the 

376 # default for the next one. This did not work, because that way, the 

377 # priorities like described in the `Style` class don't work. 

378 # 'class:aborted' was for instance never displayed in gray, because 

379 # the next style specified a default color for any text. (The 

380 # explicit styling of class:aborted should have taken priority, 

381 # because it was more precise.) 

382 def __init__(self, styles: list[BaseStyle]) -> None: 

383 self.styles = styles 

384 self._style: SimpleCache[Hashable, Style] = SimpleCache(maxsize=1) 

385 

386 @property 

387 def _merged_style(self) -> Style: 

388 "The `Style` object that has the other styles merged together." 

389 

390 def get() -> Style: 

391 return Style(self.style_rules) 

392 

393 return self._style.get(self.invalidation_hash(), get) 

394 

395 @property 

396 def style_rules(self) -> list[tuple[str, str]]: 

397 style_rules = [] 

398 for s in self.styles: 

399 style_rules.extend(s.style_rules) 

400 return style_rules 

401 

402 def get_attrs_for_style_str( 

403 self, style_str: str, default: Attrs = DEFAULT_ATTRS 

404 ) -> Attrs: 

405 return self._merged_style.get_attrs_for_style_str(style_str, default) 

406 

407 def invalidation_hash(self) -> Hashable: 

408 return tuple(s.invalidation_hash() for s in self.styles)