1- from __future__ import annotations
2- import io
31import os
42import sys
53
4+ from collections .abc import Callable , Iterator , Mapping
5+ from dataclasses import dataclass , field , Field
6+
67COLORIZE = True
78
9+
810# types
911if False :
10- from typing import IO
12+ from typing import IO , Self , ClassVar
13+ _theme : Theme
1114
1215
1316class ANSIColors :
@@ -17,11 +20,13 @@ class ANSIColors:
1720 BLUE = "\x1b [34m"
1821 CYAN = "\x1b [36m"
1922 GREEN = "\x1b [32m"
23+ GREY = "\x1b [90m"
2024 MAGENTA = "\x1b [35m"
2125 RED = "\x1b [31m"
2226 WHITE = "\x1b [37m" # more like LIGHT GRAY
2327 YELLOW = "\x1b [33m"
2428
29+ BOLD = "\x1b [1m"
2530 BOLD_BLACK = "\x1b [1;30m" # DARK GRAY
2631 BOLD_BLUE = "\x1b [1;34m"
2732 BOLD_CYAN = "\x1b [1;36m"
@@ -60,13 +65,196 @@ class ANSIColors:
6065 INTENSE_BACKGROUND_YELLOW = "\x1b [103m"
6166
6267
68+ ColorCodes = set ()
6369NoColors = ANSIColors ()
6470
65- for attr in dir ( NoColors ):
71+ for attr , code in ANSIColors . __dict__ . items ( ):
6672 if not attr .startswith ("__" ):
73+ ColorCodes .add (code )
6774 setattr (NoColors , attr , "" )
6875
6976
77+ #
78+ # Experimental theming support (see gh-133346)
79+ #
80+
81+ # - Create a theme by copying an existing `Theme` with one or more sections
82+ # replaced, using `default_theme.copy_with()`;
83+ # - create a theme section by copying an existing `ThemeSection` with one or
84+ # more colors replaced, using for example `default_theme.syntax.copy_with()`;
85+ # - create a theme from scratch by instantiating a `Theme` data class with
86+ # the required sections (which are also dataclass instances).
87+ #
88+ # Then call `_colorize.set_theme(your_theme)` to set it.
89+ #
90+ # Put your theme configuration in $PYTHONSTARTUP for the interactive shell,
91+ # or sitecustomize.py in your virtual environment or Python installation for
92+ # other uses. Your applications can call `_colorize.set_theme()` too.
93+ #
94+ # Note that thanks to the dataclasses providing default values for all fields,
95+ # creating a new theme or theme section from scratch is possible without
96+ # specifying all keys.
97+ #
98+ # For example, here's a theme that makes punctuation and operators less prominent:
99+ #
100+ # try:
101+ # from _colorize import set_theme, default_theme, Syntax, ANSIColors
102+ # except ImportError:
103+ # pass
104+ # else:
105+ # theme_with_dim_operators = default_theme.copy_with(
106+ # syntax=Syntax(op=ANSIColors.INTENSE_BLACK),
107+ # )
108+ # set_theme(theme_with_dim_operators)
109+ # del set_theme, default_theme, Syntax, ANSIColors, theme_with_dim_operators
110+ #
111+ # Guarding the import ensures that your .pythonstartup file will still work in
112+ # Python 3.13 and older. Deleting the variables ensures they don't remain in your
113+ # interactive shell's global scope.
114+
115+ class ThemeSection (Mapping [str , str ]):
116+ """A mixin/base class for theme sections.
117+
118+ It enables dictionary access to a section, as well as implements convenience
119+ methods.
120+ """
121+
122+ # The two types below are just that: types to inform the type checker that the
123+ # mixin will work in context of those fields existing
124+ __dataclass_fields__ : ClassVar [dict [str , Field [str ]]]
125+ _name_to_value : Callable [[str ], str ]
126+
127+ def __post_init__ (self ) -> None :
128+ name_to_value = {}
129+ for color_name in self .__dataclass_fields__ :
130+ name_to_value [color_name ] = getattr (self , color_name )
131+ super ().__setattr__ ('_name_to_value' , name_to_value .__getitem__ )
132+
133+ def copy_with (self , ** kwargs : str ) -> Self :
134+ color_state : dict [str , str ] = {}
135+ for color_name in self .__dataclass_fields__ :
136+ color_state [color_name ] = getattr (self , color_name )
137+ color_state .update (kwargs )
138+ return type (self )(** color_state )
139+
140+ @classmethod
141+ def no_colors (cls ) -> Self :
142+ color_state : dict [str , str ] = {}
143+ for color_name in cls .__dataclass_fields__ :
144+ color_state [color_name ] = ""
145+ return cls (** color_state )
146+
147+ def __getitem__ (self , key : str ) -> str :
148+ return self ._name_to_value (key )
149+
150+ def __len__ (self ) -> int :
151+ return len (self .__dataclass_fields__ )
152+
153+ def __iter__ (self ) -> Iterator [str ]:
154+ return iter (self .__dataclass_fields__ )
155+
156+
157+ @dataclass (frozen = True , kw_only = True )
158+ class Argparse (ThemeSection ):
159+ usage : str = ANSIColors .BOLD_BLUE
160+ prog : str = ANSIColors .BOLD_MAGENTA
161+ prog_extra : str = ANSIColors .MAGENTA
162+ heading : str = ANSIColors .BOLD_BLUE
163+ summary_long_option : str = ANSIColors .CYAN
164+ summary_short_option : str = ANSIColors .GREEN
165+ summary_label : str = ANSIColors .YELLOW
166+ summary_action : str = ANSIColors .GREEN
167+ long_option : str = ANSIColors .BOLD_CYAN
168+ short_option : str = ANSIColors .BOLD_GREEN
169+ label : str = ANSIColors .BOLD_YELLOW
170+ action : str = ANSIColors .BOLD_GREEN
171+ reset : str = ANSIColors .RESET
172+
173+
174+ @dataclass (frozen = True )
175+ class Syntax (ThemeSection ):
176+ prompt : str = ANSIColors .BOLD_MAGENTA
177+ keyword : str = ANSIColors .BOLD_BLUE
178+ keyword_constant : 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+
70258def get_colors (
71259 colorize : bool = False , * , file : IO [str ] | IO [bytes ] | None = None
72260) -> ANSIColors :
@@ -76,22 +264,37 @@ def get_colors(
76264 return NoColors
77265
78266
267+ def decolor (text : str ) -> str :
268+ """Remove ANSI color codes from a string."""
269+ for code in ColorCodes :
270+ text = text .replace (code , "" )
271+ return text
272+
273+
79274def can_colorize (* , file : IO [str ] | IO [bytes ] | None = None ) -> bool :
275+
276+ def _safe_getenv (k : str , fallback : str | None = None ) -> str | None :
277+ """Exception-safe environment retrieval. See gh-128636."""
278+ try :
279+ return os .environ .get (k , fallback )
280+ except Exception :
281+ return fallback
282+
80283 if file is None :
81284 file = sys .stdout
82285
83286 if not sys .flags .ignore_environment :
84- if os . environ . get ("PYTHON_COLORS" ) == "0" :
287+ if _safe_getenv ("PYTHON_COLORS" ) == "0" :
85288 return False
86- if os . environ . get ("PYTHON_COLORS" ) == "1" :
289+ if _safe_getenv ("PYTHON_COLORS" ) == "1" :
87290 return True
88- if os . environ . get ("NO_COLOR" ):
291+ if _safe_getenv ("NO_COLOR" ):
89292 return False
90293 if not COLORIZE :
91294 return False
92- if os . environ . get ("FORCE_COLOR" ):
295+ if _safe_getenv ("FORCE_COLOR" ):
93296 return True
94- if os . environ . get ("TERM" ) == "dumb" :
297+ if _safe_getenv ("TERM" ) == "dumb" :
95298 return False
96299
97300 if not hasattr (file , "fileno" ):
@@ -108,5 +311,45 @@ def can_colorize(*, file: IO[str] | IO[bytes] | None = None) -> bool:
108311
109312 try :
110313 return os .isatty (file .fileno ())
111- except io . UnsupportedOperation :
314+ except OSError :
112315 return hasattr (file , "isatty" ) and file .isatty ()
316+
317+
318+ default_theme = Theme ()
319+ theme_no_color = default_theme .no_colors ()
320+
321+
322+ def get_theme (
323+ * ,
324+ tty_file : IO [str ] | IO [bytes ] | None = None ,
325+ force_color : bool = False ,
326+ force_no_color : bool = False ,
327+ ) -> Theme :
328+ """Returns the currently set theme, potentially in a zero-color variant.
329+
330+ In cases where colorizing is not possible (see `can_colorize`), the returned
331+ theme contains all empty strings in all color definitions.
332+ See `Theme.no_colors()` for more information.
333+
334+ It is recommended not to cache the result of this function for extended
335+ periods of time because the user might influence theme selection by
336+ the interactive shell, a debugger, or application-specific code. The
337+ environment (including environment variable state and console configuration
338+ on Windows) can also change in the course of the application life cycle.
339+ """
340+ if force_color or (not force_no_color and
341+ can_colorize (file = tty_file )):
342+ return _theme
343+ return theme_no_color
344+
345+
346+ def set_theme (t : Theme ) -> None :
347+ global _theme
348+
349+ if not isinstance (t , Theme ):
350+ raise ValueError (f"Expected Theme object, found { t } " )
351+
352+ _theme = t
353+
354+
355+ set_theme (default_theme )
0 commit comments