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

172 statements  

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)