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

1""" 

2Tool for creating styles from a dictionary. 

3""" 

4from __future__ import annotations 

5 

6import itertools 

7import re 

8from enum import Enum 

9from typing import Hashable, TypeVar 

10 

11from prompt_toolkit.cache import SimpleCache 

12 

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 

21 

22__all__ = [ 

23 "Style", 

24 "parse_color", 

25 "Priority", 

26 "merge_styles", 

27] 

28 

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

30 

31 

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

33 """ 

34 Parse/validate color format. 

35 

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] 

44 

45 # 140 named colors. 

46 try: 

47 # Replace by 'hex' value. 

48 return _named_colors_lowercase[text.lower()] 

49 except KeyError: 

50 pass 

51 

52 # Hex codes. 

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

54 col = text[1:] 

55 

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] 

62 

63 # 6 digit hex color. 

64 elif len(col) == 6: 

65 return col 

66 

67 # 3 digit hex color. 

68 elif len(col) == 3: 

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

70 

71 # Default. 

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

73 return text 

74 

75 raise ValueError("Wrong color format %r" % text) 

76 

77 

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) 

91 

92 

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

94 """ 

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

96 

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

98 """ 

99 result = [] 

100 parts = classname.split(".") 

101 

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

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

104 

105 return result 

106 

107 

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 

118 

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) 

139 

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) 

153 

154 # Pygments properties that we ignore. 

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

156 pass 

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

158 pass 

159 

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 

164 

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)) 

172 

173 return attrs 

174 

175 

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

177 

178 

179class Priority(Enum): 

180 """ 

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

182 

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. 

186 

187 We have two options: 

188 

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 """ 

195 

196 DICT_KEY_ORDER = "KEY_ORDER" 

197 MOST_PRECISE = "MOST_PRECISE" 

198 

199 

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 

203 

204 

205class Style(BaseStyle): 

206 """ 

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

208 

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'. 

213 

214 Later rules always override previous rules. 

215 

216 Usage:: 

217 

218 Style([ 

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

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

221 ('class1 class2', 'reverse'), 

222 ]) 

223 

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

225 """ 

226 

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

228 class_names_and_attrs = [] 

229 

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) 

234 

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) 

239 

240 class_names_and_attrs.append((class_names_set, attrs)) 

241 

242 self._style_rules = style_rules 

243 self.class_names_and_attrs = class_names_and_attrs 

244 

245 @property 

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

247 return self._style_rules 

248 

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: 

258 

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()) 

262 

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

264 else: 

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

266 

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() 

275 

276 # Apply default styling. 

277 for names, attr in self.class_names_and_attrs: 

278 if not names: 

279 list_of_attrs.append(attr) 

280 

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)) 

292 

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])) 

297 

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,))) 

301 

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) 

306 

307 class_names.add(new_name) 

308 

309 # Process inline style. 

310 else: 

311 inline_attrs = _parse_style_str(part) 

312 list_of_attrs.append(inline_attrs) 

313 

314 return _merge_attrs(list_of_attrs) 

315 

316 def invalidation_hash(self) -> Hashable: 

317 return id(self.class_names_and_attrs) 

318 

319 

320_T = TypeVar("_T") 

321 

322 

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 """ 

329 

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. 

336 

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 ) 

348 

349 

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) 

356 

357 

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 """ 

364 

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) 

377 

378 @property 

379 def _merged_style(self) -> Style: 

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

381 

382 def get() -> Style: 

383 return Style(self.style_rules) 

384 

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

386 

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 

393 

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) 

398 

399 def invalidation_hash(self) -> Hashable: 

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