Skip to content

Commit 793b486

Browse files
authored
feat: new color system (#842)
Close #838. Signed-off-by: Henry Schreiner <[email protected]>
1 parent 74dd119 commit 793b486

File tree

14 files changed

+424
-129
lines changed

14 files changed

+424
-129
lines changed

docs/configuration.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -704,22 +704,25 @@ You can add a message to be printed after a successful or failed build. For exam
704704

705705
```toml
706706
[tool.scikit-build]
707-
messages.after-sucesss = "[green]Wheel successfully built"
707+
messages.after-sucesss = "{green}Wheel successfully built"
708708
messages.after-failure = """
709-
[red bold]Sorry[/bold], build failed.
709+
{bold.red}Sorry{normal}, build failed. Your platform is {platform.platform}.
710710
"""
711711
```
712712

713-
This will be run through Python's formatter, so escape curly brackets. There
714-
currently are no items provided in the format call, but some may be added in the
715-
future if requested.
713+
This will be run through Python's formatter, so escape curly brackets if you
714+
need them. Currently, there are several formatter-style keywords available:
715+
`sys`, `platform` (parenthesis will be added for items like `platform.platform`
716+
for you), `__version__` for scikit-build-core's version, and style keywords.
716717

717-
A small mini-language for colorization using `[]` is also provided somewhat
718-
similar to [Rich](https://rich.readthedocs.io). Supported keywords inside the
719-
square brackets are `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, and
720-
`bold`. These can be reset with a proceeding `/`. `reset` is also supported.
721-
Unsupported options will be ignored, but keep in mind other keywords from Rich
722-
might be added in the future.
718+
For styles, the colors are `default`, `red`, `green`, `yellow`, `blue`,
719+
`magenta`, `cyan`, and `white`. These can be accessed as `fg.*` or `bg.*`,
720+
without a qualifier the foreground is assumed. Styles like `normal`, `bold`,
721+
`italic`, `underline`, `reverse` are also provided. A full clearing of all
722+
styles is possible with `reset`. These all can be chained, as well, so
723+
`bold.red.bg.blue` is valid, and will produce an optimized escape code.
724+
Remember that you need to set the environment variable `FORCE_COLOR` to see
725+
colors with pip.
723726

724727
## Other options
725728

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ extend-select = [
252252
ignore = [
253253
"PLE1205", # Format check doesn't work with our custom logger
254254
"PT004", # Incorrect, just usefixtures instead.
255+
"PT013", # It's correct to import classes for typing!
255256
"RUF009", # Too easy to get a false positive
256257
"PYI025", # Wants Set to be renamed AbstractSet
257258
"ISC001", # Conflicts with formatter

src/scikit_build_core/_logging.py

Lines changed: 252 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import dataclasses
5+
import enum
46
import functools
57
import logging
68
import os
7-
import re
9+
import platform
810
import sys
9-
from typing import Any, NoReturn
11+
from collections.abc import Mapping
12+
from typing import TYPE_CHECKING, Any, NoReturn
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Iterator
16+
17+
from ._compat.typing import Literal, Self
18+
19+
StrMapping = Mapping[str, "Style"]
20+
else:
21+
StrMapping = Mapping
22+
23+
from . import __version__
1024

1125
__all__ = [
1226
"ScikitBuildLogger",
@@ -16,6 +30,7 @@
1630
"rich_warning",
1731
"rich_error",
1832
"LEVEL_VALUE",
33+
"Style",
1934
]
2035

2136

@@ -36,6 +51,15 @@ def __dir__() -> list[str]:
3651
}
3752

3853

54+
class PlatformHelper:
55+
def __getattr__(self, name: str) -> Any:
56+
result = getattr(platform, name)
57+
return result() if callable(result) else result
58+
59+
def __repr__(self) -> str:
60+
return repr(platform)
61+
62+
3963
class FStringMessage:
4064
"This class captures a formatted string message and only produces it on demand."
4165

@@ -95,74 +119,249 @@ def addHandler(self, handler: logging.Handler) -> None: # noqa: N802
95119
logger = ScikitBuildLogger(raw_logger)
96120

97121

98-
ANY_ESCAPE = re.compile(r"\[([\w\s/]+)\]")
99-
100-
101-
_COLORS = {
102-
"red": "\33[91m",
103-
"green": "\33[92m",
104-
"yellow": "\33[93m",
105-
"blue": "\33[94m",
106-
"magenta": "\33[95m",
107-
"cyan": "\33[96m",
108-
"bold": "\33[1m",
109-
"/red": "\33[0m",
110-
"/green": "\33[0m",
111-
"/blue": "\33[0m",
112-
"/yellow": "\33[0m",
113-
"/magenta": "\33[0m",
114-
"/cyan": "\33[0m",
115-
"/bold": "\33[22m",
116-
"reset": "\33[0m",
117-
}
118-
_NO_COLORS = {color: "" for color in _COLORS}
119-
120-
121-
def colors() -> dict[str, str]:
122+
def colors() -> bool:
122123
if "NO_COLOR" in os.environ:
123-
return _NO_COLORS
124+
return False
124125
# Pip reroutes sys.stdout, so FORCE_COLOR is required there
125126
if os.environ.get("FORCE_COLOR", ""):
126-
return _COLORS
127+
return True
127128
# Avoid ValueError: I/O operation on closed file
128129
with contextlib.suppress(ValueError):
129130
# Assume sys.stderr is similar to sys.stdout
130131
isatty = sys.stdout.isatty()
131132
if isatty and not sys.platform.startswith("win"):
132-
return _COLORS
133-
return _NO_COLORS
133+
return True
134+
return False
134135

135136

136-
def _sub_rich(m: re.Match[str]) -> str:
137-
"""
138-
Replace rich-like tags, but only if they are defined in colors.
139-
"""
140-
color_dict = colors()
141-
try:
142-
return "".join(color_dict[x] for x in m.group(1).split())
143-
except KeyError:
144-
return m.group(0)
137+
class Colors(enum.Enum):
138+
black = 0
139+
red = 1
140+
green = 2
141+
yellow = 3
142+
blue = 4
143+
magenta = 5
144+
cyan = 6
145+
white = 7
146+
default = 9
147+
148+
149+
class Styles(enum.Enum):
150+
bold = 1
151+
italic = 3
152+
underline = 4
153+
reverse = 7
154+
reset = 0
155+
normal = 22
145156

146157

147-
def _process_rich(msg: object) -> str:
148-
return ANY_ESCAPE.sub(
149-
_sub_rich,
150-
str(msg),
158+
@dataclasses.dataclass(frozen=True)
159+
class Style(StrMapping):
160+
color: bool = dataclasses.field(default_factory=colors)
161+
styles: tuple[int, ...] = dataclasses.field(default_factory=tuple)
162+
current: int = 0
163+
164+
def __str__(self) -> str:
165+
styles = ";".join(str(x) for x in self.styles)
166+
return f"\33[{styles}m" if styles and self.color else ""
167+
168+
@property
169+
def fg(self) -> Self:
170+
return dataclasses.replace(self, current=30)
171+
172+
@property
173+
def bg(self) -> Self:
174+
return dataclasses.replace(self, current=40)
175+
176+
@property
177+
def bold(self) -> Self:
178+
return dataclasses.replace(self, styles=(*self.styles, Styles.bold.value))
179+
180+
@property
181+
def italic(self) -> Self:
182+
return dataclasses.replace(self, styles=(*self.styles, Styles.italic.value))
183+
184+
@property
185+
def underline(self) -> Self:
186+
return dataclasses.replace(self, styles=(*self.styles, Styles.underline.value))
187+
188+
@property
189+
def reverse(self) -> Self:
190+
return dataclasses.replace(self, styles=(*self.styles, Styles.reverse.value))
191+
192+
@property
193+
def reset(self) -> Self:
194+
return dataclasses.replace(self, styles=(Styles.reset.value,), current=0)
195+
196+
@property
197+
def normal(self) -> Self:
198+
return dataclasses.replace(self, styles=(*self.styles, Styles.normal.value))
199+
200+
@property
201+
def black(self) -> Self:
202+
return dataclasses.replace(
203+
self, styles=(*self.styles, Colors.black.value + (self.current or 30))
204+
)
205+
206+
@property
207+
def red(self) -> Self:
208+
return dataclasses.replace(
209+
self, styles=(*self.styles, Colors.red.value + (self.current or 30))
210+
)
211+
212+
@property
213+
def green(self) -> Self:
214+
return dataclasses.replace(
215+
self, styles=(*self.styles, Colors.green.value + (self.current or 30))
216+
)
217+
218+
@property
219+
def yellow(self) -> Self:
220+
return dataclasses.replace(
221+
self, styles=(*self.styles, Colors.yellow.value + (self.current or 30))
222+
)
223+
224+
@property
225+
def blue(self) -> Self:
226+
return dataclasses.replace(
227+
self, styles=(*self.styles, Colors.blue.value + (self.current or 30))
228+
)
229+
230+
@property
231+
def magenta(self) -> Self:
232+
return dataclasses.replace(
233+
self, styles=(*self.styles, Colors.magenta.value + (self.current or 30))
234+
)
235+
236+
@property
237+
def cyan(self) -> Self:
238+
return dataclasses.replace(
239+
self, styles=(*self.styles, Colors.cyan.value + (self.current or 30))
240+
)
241+
242+
@property
243+
def white(self) -> Self:
244+
return dataclasses.replace(
245+
self, styles=(*self.styles, Colors.white.value + (self.current or 30))
246+
)
247+
248+
@property
249+
def default(self) -> Self:
250+
return dataclasses.replace(
251+
self, styles=(*self.styles, Colors.default.value + (self.current or 30))
252+
)
253+
254+
_keys = (
255+
"bold",
256+
"italic",
257+
"underline",
258+
"reverse",
259+
"reset",
260+
"normal",
261+
"black",
262+
"red",
263+
"green",
264+
"yellow",
265+
"blue",
266+
"magenta",
267+
"cyan",
268+
"white",
269+
"default",
151270
)
152271

272+
def __len__(self) -> int:
273+
return len(self._keys)
153274

154-
def rich_print(*args: object, **kwargs: object) -> None:
155-
args_2 = tuple(_process_rich(arg) for arg in args)
156-
if args != args_2:
157-
args_2 = (*args_2[:-1], args_2[-1] + colors()["reset"])
158-
print(*args_2, **kwargs, flush=True) # type: ignore[call-overload] # noqa: T201
275+
def __getitem__(self, name: str) -> Self:
276+
return getattr(self, name) # type: ignore[no-any-return]
159277

278+
def __iter__(self) -> Iterator[str]:
279+
return iter(self._keys)
160280

161-
@functools.lru_cache(maxsize=None)
162-
def rich_warning(*args: object, **kwargs: object) -> None:
163-
rich_print("[red][yellow]WARNING:[/bold]", *args, **kwargs)
164281

282+
_style = Style()
283+
_nostyle = Style(color=False)
165284

166-
def rich_error(*args: object, **kwargs: object) -> NoReturn:
167-
rich_print("[red][bold]ERROR:[/bold]", *args, **kwargs)
285+
286+
def rich_print(
287+
*args: object,
288+
file: object = None,
289+
sep: str = " ",
290+
end: str = "\n",
291+
color: Literal[
292+
"", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
293+
] = "",
294+
**kwargs: object,
295+
) -> None:
296+
"""
297+
Print a message with style and useful common includes provided via formatting.
298+
299+
This function will process every argument with the following formatting:
300+
301+
- ``{__version__}``: The version of scikit-build-core.
302+
- ``{platform}``: The platform module.
303+
- ``{sys}``: The sys module.
304+
- Colors and styles.
305+
306+
Any keyword arguments will be passed directly to the `str.format` method
307+
unless they conflict with the above. ``print`` arguments work as normal, and
308+
the output will be flushed.
309+
310+
Each argument will clear the style afterwards if a style is applied. The
311+
``color=`` argument will set a default color to apply to every argument, and
312+
is available to arguments as ``{color}``.
313+
"""
314+
if color:
315+
kwargs["color"] = _style[color]
316+
317+
args_1 = tuple(str(arg) for arg in args)
318+
args_1_gen = (
319+
arg.format(
320+
__version__=__version__,
321+
platform=PlatformHelper(),
322+
sys=sys,
323+
**_nostyle,
324+
**kwargs,
325+
)
326+
for arg in args_1
327+
)
328+
args_2_gen = (
329+
arg.format(
330+
__version__=__version__,
331+
platform=PlatformHelper(),
332+
sys=sys,
333+
**_style,
334+
**kwargs,
335+
)
336+
for arg in args_1
337+
)
338+
if color:
339+
args_2 = (f"{_style[color]}{new}{_style.reset}" for new in args_2_gen)
340+
else:
341+
args_2 = (
342+
new if new == orig else f"{new}{_style.reset}"
343+
for new, orig in zip(args_2_gen, args_1_gen)
344+
)
345+
print(*args_2, flush=True, sep=sep, end=end, file=file) # type: ignore[call-overload]
346+
347+
348+
@functools.lru_cache(maxsize=None)
349+
def rich_warning(
350+
*args: str,
351+
color: Literal[
352+
"", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
353+
] = "yellow",
354+
**kwargs: object,
355+
) -> None:
356+
rich_print("{bold.yellow}WARNING:", *args, color=color, **kwargs) # type: ignore[arg-type]
357+
358+
359+
def rich_error(
360+
*args: str,
361+
color: Literal[
362+
"", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
363+
] = "red",
364+
**kwargs: object,
365+
) -> NoReturn:
366+
rich_print("{bold.red}ERROR:", *args, color=color, **kwargs) # type: ignore[arg-type]
168367
raise SystemExit(7)

0 commit comments

Comments
 (0)