|
1 |
| -from __future__ import annotations |
2 | 1 | import io
|
3 | 2 | import os
|
4 | 3 | import sys
|
5 | 4 |
|
| 5 | +from collections.abc import Callable, Iterator, Mapping |
| 6 | +from dataclasses import dataclass, field, Field |
| 7 | + |
6 | 8 | COLORIZE = True
|
7 | 9 |
|
| 10 | + |
8 | 11 | # types
|
9 | 12 | if False:
|
10 |
| - from typing import IO, Literal |
11 |
| - |
12 |
| - type ColorTag = Literal[ |
13 |
| - "PROMPT", |
14 |
| - "KEYWORD", |
15 |
| - "BUILTIN", |
16 |
| - "COMMENT", |
17 |
| - "STRING", |
18 |
| - "NUMBER", |
19 |
| - "OP", |
20 |
| - "DEFINITION", |
21 |
| - "SOFT_KEYWORD", |
22 |
| - "RESET", |
23 |
| - ] |
24 |
| - |
25 |
| - theme: dict[ColorTag, str] |
| 13 | + from typing import IO, Self, ClassVar |
| 14 | + _theme: Theme |
26 | 15 |
|
27 | 16 |
|
28 | 17 | class ANSIColors:
|
@@ -86,6 +75,186 @@ class ANSIColors:
|
86 | 75 | setattr(NoColors, attr, "")
|
87 | 76 |
|
88 | 77 |
|
| 78 | +# |
| 79 | +# Experimental theming support (see gh-133346) |
| 80 | +# |
| 81 | + |
| 82 | +# - Create a theme by copying an existing `Theme` with one or more sections |
| 83 | +# replaced, using `default_theme.copy_with()`; |
| 84 | +# - create a theme section by copying an existing `ThemeSection` with one or |
| 85 | +# more colors replaced, using for example `default_theme.syntax.copy_with()`; |
| 86 | +# - create a theme from scratch by instantiating a `Theme` data class with |
| 87 | +# the required sections (which are also dataclass instances). |
| 88 | +# |
| 89 | +# Then call `_colorize.set_theme(your_theme)` to set it. |
| 90 | +# |
| 91 | +# Put your theme configuration in $PYTHONSTARTUP for the interactive shell, |
| 92 | +# or sitecustomize.py in your virtual environment or Python installation for |
| 93 | +# other uses. Your applications can call `_colorize.set_theme()` too. |
| 94 | +# |
| 95 | +# Note that thanks to the dataclasses providing default values for all fields, |
| 96 | +# creating a new theme or theme section from scratch is possible without |
| 97 | +# specifying all keys. |
| 98 | +# |
| 99 | +# For example, here's a theme that makes punctuation and operators less prominent: |
| 100 | +# |
| 101 | +# try: |
| 102 | +# from _colorize import set_theme, default_theme, Syntax, ANSIColors |
| 103 | +# except ImportError: |
| 104 | +# pass |
| 105 | +# else: |
| 106 | +# theme_with_dim_operators = default_theme.copy_with( |
| 107 | +# syntax=Syntax(op=ANSIColors.INTENSE_BLACK), |
| 108 | +# ) |
| 109 | +# set_theme(theme_with_dim_operators) |
| 110 | +# del set_theme, default_theme, Syntax, ANSIColors, theme_with_dim_operators |
| 111 | +# |
| 112 | +# Guarding the import ensures that your .pythonstartup file will still work in |
| 113 | +# Python 3.13 and older. Deleting the variables ensures they don't remain in your |
| 114 | +# interactive shell's global scope. |
| 115 | + |
| 116 | +class ThemeSection(Mapping[str, str]): |
| 117 | + """A mixin/base class for theme sections. |
| 118 | +
|
| 119 | + It enables dictionary access to a section, as well as implements convenience |
| 120 | + methods. |
| 121 | + """ |
| 122 | + |
| 123 | + # The two types below are just that: types to inform the type checker that the |
| 124 | + # mixin will work in context of those fields existing |
| 125 | + __dataclass_fields__: ClassVar[dict[str, Field[str]]] |
| 126 | + _name_to_value: Callable[[str], str] |
| 127 | + |
| 128 | + def __post_init__(self) -> None: |
| 129 | + name_to_value = {} |
| 130 | + for color_name in self.__dataclass_fields__: |
| 131 | + name_to_value[color_name] = getattr(self, color_name) |
| 132 | + super().__setattr__('_name_to_value', name_to_value.__getitem__) |
| 133 | + |
| 134 | + def copy_with(self, **kwargs: str) -> Self: |
| 135 | + color_state: dict[str, str] = {} |
| 136 | + for color_name in self.__dataclass_fields__: |
| 137 | + color_state[color_name] = getattr(self, color_name) |
| 138 | + color_state.update(kwargs) |
| 139 | + return type(self)(**color_state) |
| 140 | + |
| 141 | + @classmethod |
| 142 | + def no_colors(cls) -> Self: |
| 143 | + color_state: dict[str, str] = {} |
| 144 | + for color_name in cls.__dataclass_fields__: |
| 145 | + color_state[color_name] = "" |
| 146 | + return cls(**color_state) |
| 147 | + |
| 148 | + def __getitem__(self, key: str) -> str: |
| 149 | + return self._name_to_value(key) |
| 150 | + |
| 151 | + def __len__(self) -> int: |
| 152 | + return len(self.__dataclass_fields__) |
| 153 | + |
| 154 | + def __iter__(self) -> Iterator[str]: |
| 155 | + return iter(self.__dataclass_fields__) |
| 156 | + |
| 157 | + |
| 158 | +@dataclass(frozen=True) |
| 159 | +class Argparse(ThemeSection): |
| 160 | + usage: str = ANSIColors.BOLD_BLUE |
| 161 | + prog: str = ANSIColors.BOLD_MAGENTA |
| 162 | + prog_extra: str = ANSIColors.MAGENTA |
| 163 | + heading: str = ANSIColors.BOLD_BLUE |
| 164 | + summary_long_option: str = ANSIColors.CYAN |
| 165 | + summary_short_option: str = ANSIColors.GREEN |
| 166 | + summary_label: str = ANSIColors.YELLOW |
| 167 | + summary_action: str = ANSIColors.GREEN |
| 168 | + long_option: str = ANSIColors.BOLD_CYAN |
| 169 | + short_option: str = ANSIColors.BOLD_GREEN |
| 170 | + label: str = ANSIColors.BOLD_YELLOW |
| 171 | + action: str = ANSIColors.BOLD_GREEN |
| 172 | + reset: str = ANSIColors.RESET |
| 173 | + |
| 174 | + |
| 175 | +@dataclass(frozen=True) |
| 176 | +class Syntax(ThemeSection): |
| 177 | + prompt: str = ANSIColors.BOLD_MAGENTA |
| 178 | + keyword: str = ANSIColors.BOLD_BLUE |
| 179 | + builtin: str = ANSIColors.CYAN |
| 180 | + comment: str = ANSIColors.RED |
| 181 | + string: str = ANSIColors.GREEN |
| 182 | + number: str = ANSIColors.YELLOW |
| 183 | + op: str = ANSIColors.RESET |
| 184 | + definition: str = ANSIColors.BOLD |
| 185 | + soft_keyword: str = ANSIColors.BOLD_BLUE |
| 186 | + reset: str = ANSIColors.RESET |
| 187 | + |
| 188 | + |
| 189 | +@dataclass(frozen=True) |
| 190 | +class Traceback(ThemeSection): |
| 191 | + type: str = ANSIColors.BOLD_MAGENTA |
| 192 | + message: str = ANSIColors.MAGENTA |
| 193 | + filename: str = ANSIColors.MAGENTA |
| 194 | + line_no: str = ANSIColors.MAGENTA |
| 195 | + frame: str = ANSIColors.MAGENTA |
| 196 | + error_highlight: str = ANSIColors.BOLD_RED |
| 197 | + error_range: str = ANSIColors.RED |
| 198 | + reset: str = ANSIColors.RESET |
| 199 | + |
| 200 | + |
| 201 | +@dataclass(frozen=True) |
| 202 | +class Unittest(ThemeSection): |
| 203 | + passed: str = ANSIColors.GREEN |
| 204 | + warn: str = ANSIColors.YELLOW |
| 205 | + fail: str = ANSIColors.RED |
| 206 | + fail_info: str = ANSIColors.BOLD_RED |
| 207 | + reset: str = ANSIColors.RESET |
| 208 | + |
| 209 | + |
| 210 | +@dataclass(frozen=True) |
| 211 | +class Theme: |
| 212 | + """A suite of themes for all sections of Python. |
| 213 | +
|
| 214 | + When adding a new one, remember to also modify `copy_with` and `no_colors` |
| 215 | + below. |
| 216 | + """ |
| 217 | + argparse: Argparse = field(default_factory=Argparse) |
| 218 | + syntax: Syntax = field(default_factory=Syntax) |
| 219 | + traceback: Traceback = field(default_factory=Traceback) |
| 220 | + unittest: Unittest = field(default_factory=Unittest) |
| 221 | + |
| 222 | + def copy_with( |
| 223 | + self, |
| 224 | + *, |
| 225 | + argparse: Argparse | None = None, |
| 226 | + syntax: Syntax | None = None, |
| 227 | + traceback: Traceback | None = None, |
| 228 | + unittest: Unittest | None = None, |
| 229 | + ) -> Self: |
| 230 | + """Return a new Theme based on this instance with some sections replaced. |
| 231 | +
|
| 232 | + Themes are immutable to protect against accidental modifications that |
| 233 | + could lead to invalid terminal states. |
| 234 | + """ |
| 235 | + return type(self)( |
| 236 | + argparse=argparse or self.argparse, |
| 237 | + syntax=syntax or self.syntax, |
| 238 | + traceback=traceback or self.traceback, |
| 239 | + unittest=unittest or self.unittest, |
| 240 | + ) |
| 241 | + |
| 242 | + @classmethod |
| 243 | + def no_colors(cls) -> Self: |
| 244 | + """Return a new Theme where colors in all sections are empty strings. |
| 245 | +
|
| 246 | + This allows writing user code as if colors are always used. The color |
| 247 | + fields will be ANSI color code strings when colorization is desired |
| 248 | + and possible, and empty strings otherwise. |
| 249 | + """ |
| 250 | + return cls( |
| 251 | + argparse=Argparse.no_colors(), |
| 252 | + syntax=Syntax.no_colors(), |
| 253 | + traceback=Traceback.no_colors(), |
| 254 | + unittest=Unittest.no_colors(), |
| 255 | + ) |
| 256 | + |
| 257 | + |
89 | 258 | def get_colors(
|
90 | 259 | colorize: bool = False, *, file: IO[str] | IO[bytes] | None = None
|
91 | 260 | ) -> ANSIColors:
|
@@ -138,26 +307,40 @@ def can_colorize(*, file: IO[str] | IO[bytes] | None = None) -> bool:
|
138 | 307 | return hasattr(file, "isatty") and file.isatty()
|
139 | 308 |
|
140 | 309 |
|
141 |
| -def set_theme(t: dict[ColorTag, str] | None = None) -> None: |
142 |
| - global theme |
| 310 | +default_theme = Theme() |
| 311 | +theme_no_color = default_theme.no_colors() |
| 312 | + |
| 313 | + |
| 314 | +def get_theme( |
| 315 | + *, |
| 316 | + tty_file: IO[str] | IO[bytes] | None = None, |
| 317 | + force_color: bool = False, |
| 318 | + force_no_color: bool = False, |
| 319 | +) -> Theme: |
| 320 | + """Returns the currently set theme, potentially in a zero-color variant. |
| 321 | +
|
| 322 | + In cases where colorizing is not possible (see `can_colorize`), the returned |
| 323 | + theme contains all empty strings in all color definitions. |
| 324 | + See `Theme.no_colors()` for more information. |
| 325 | +
|
| 326 | + It is recommended not to cache the result of this function for extended |
| 327 | + periods of time because the user might influence theme selection by |
| 328 | + the interactive shell, a debugger, or application-specific code. The |
| 329 | + environment (including environment variable state and console configuration |
| 330 | + on Windows) can also change in the course of the application life cycle. |
| 331 | + """ |
| 332 | + if force_color or (not force_no_color and can_colorize(file=tty_file)): |
| 333 | + return _theme |
| 334 | + return theme_no_color |
| 335 | + |
| 336 | + |
| 337 | +def set_theme(t: Theme) -> None: |
| 338 | + global _theme |
143 | 339 |
|
144 |
| - if t: |
145 |
| - theme = t |
146 |
| - return |
| 340 | + if not isinstance(t, Theme): |
| 341 | + raise ValueError(f"Expected Theme object, found {t}") |
147 | 342 |
|
148 |
| - colors = get_colors() |
149 |
| - theme = { |
150 |
| - "PROMPT": colors.BOLD_MAGENTA, |
151 |
| - "KEYWORD": colors.BOLD_BLUE, |
152 |
| - "BUILTIN": colors.CYAN, |
153 |
| - "COMMENT": colors.RED, |
154 |
| - "STRING": colors.GREEN, |
155 |
| - "NUMBER": colors.YELLOW, |
156 |
| - "OP": colors.RESET, |
157 |
| - "DEFINITION": colors.BOLD, |
158 |
| - "SOFT_KEYWORD": colors.BOLD_BLUE, |
159 |
| - "RESET": colors.RESET, |
160 |
| - } |
| 343 | + _theme = t |
161 | 344 |
|
162 | 345 |
|
163 |
| -set_theme() |
| 346 | +set_theme(default_theme) |
0 commit comments