Skip to content

Commit de85ec5

Browse files
committed
Enhances logging with customizable rich formatting
1 parent 79ed29d commit de85ec5

File tree

1 file changed

+164
-17
lines changed

1 file changed

+164
-17
lines changed

src/easydiffraction/utils/logging.py

Lines changed: 164 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,44 @@
1515
if TYPE_CHECKING: # pragma: no cover
1616
from types import TracebackType
1717

18+
import re
19+
from pathlib import Path
20+
from typing import TYPE_CHECKING
21+
22+
from rich.console import Console
23+
from rich.console import Group
24+
from rich.console import RenderableType
1825
from rich.logging import RichHandler
26+
from rich.padding import Padding
27+
from rich.text import Text
28+
29+
CONSOLE_WIDTH = 1000
30+
31+
32+
class IconRichHandler(RichHandler):
33+
_icons = {
34+
logging.CRITICAL: '💀',
35+
logging.ERROR: '❌',
36+
logging.WARNING: '⚠️',
37+
logging.DEBUG: '💀',
38+
logging.INFO: 'ℹ️',
39+
}
40+
41+
@staticmethod
42+
def in_warp() -> bool:
43+
return os.getenv('TERM_PROGRAM') == 'WarpTerminal'
44+
45+
def get_level_text(self, record: logging.LogRecord) -> Text:
46+
icon = self._icons.get(record.levelno, record.levelname)
47+
if self.in_warp() and icon in ['⚠️', '⚙️', 'ℹ️']:
48+
icon = icon + ' ' # add space to avoid rendering issues in Warp
49+
return Text(icon)
50+
51+
def render_message(self, record: logging.LogRecord, message: str) -> Text:
52+
icon = self._icons.get(record.levelno, record.levelname)
53+
record = logging.makeLogRecord(record.__dict__)
54+
record.levelname = icon
55+
return super().render_message(record, message)
1956

2057

2158
class Logger:
@@ -51,12 +88,59 @@ class Reaction(Enum):
5188
_configured = False
5289
_mode: 'Logger.Mode' = Mode.VERBOSE
5390
_reaction: 'Logger.Reaction' = Reaction.RAISE # TODO: not default?
91+
_console = Console(width=CONSOLE_WIDTH, force_jupyter=False)
92+
93+
@classmethod
94+
def print2(cls, *objects, **kwargs):
95+
"""Print objects to the console with left padding.
96+
97+
- Renderables (Rich types like Text, Table, Panel, etc.) are
98+
kept as-is.
99+
- Non-renderables (ints, floats, Path, etc.) are converted to
100+
str().
101+
"""
102+
safe_objects = []
103+
for obj in objects:
104+
if isinstance(obj, (str, RenderableType)):
105+
safe_objects.append(obj)
106+
elif isinstance(obj, Path):
107+
# Rich can render Path objects, but str() ensures
108+
# consistency
109+
safe_objects.append(str(obj))
110+
else:
111+
safe_objects.append(str(obj))
112+
113+
# Join with spaces, like print()
114+
padded = Padding(*safe_objects, (0, 0, 0, 3))
115+
cls._console.print(padded, **kwargs)
116+
117+
@classmethod
118+
def print(cls, *objects, **kwargs):
119+
"""Print objects to the console with left padding."""
120+
safe_objects = []
121+
for obj in objects:
122+
if isinstance(obj, RenderableType):
123+
safe_objects.append(obj)
124+
elif isinstance(obj, Path):
125+
safe_objects.append(str(obj))
126+
else:
127+
safe_objects.append(str(obj))
128+
129+
# If multiple objects, join with spaces
130+
renderable = (
131+
' '.join(str(o) for o in safe_objects)
132+
if all(isinstance(o, str) for o in safe_objects)
133+
else Group(*safe_objects)
134+
)
135+
136+
padded = Padding(renderable, (0, 0, 0, 3))
137+
cls._console.print(padded, **kwargs)
54138

55139
# ---------------- environment detection ----------------
56140
@staticmethod
57141
def _in_jupyter() -> bool: # pragma: no cover - heuristic
58142
try:
59-
from IPython import get_ipython # type: ignore[import-not-found]
143+
from IPython import get_ipython
60144

61145
return get_ipython() is not None
62146
except Exception: # noqa: BLE001
@@ -138,7 +222,7 @@ def configure(
138222
# locals_max_string=0, # no local string previews
139223
)
140224
console = Console(
141-
width=120,
225+
width=CONSOLE_WIDTH,
142226
# color_system="truecolor",
143227
force_jupyter=False,
144228
# force_terminal=False,
@@ -149,8 +233,8 @@ def configure(
149233
handler = RichHandler(
150234
rich_tracebacks=rich_tracebacks,
151235
markup=True,
152-
show_time=False,
153-
show_path=False,
236+
show_time=False, # show_time=(mode == cls.Mode.VERBOSE),
237+
show_path=False, # show_path=(mode == cls.Mode.VERBOSE),
154238
tracebacks_show_locals=False,
155239
tracebacks_suppress=['easydiffraction'],
156240
tracebacks_max_frames=10,
@@ -250,6 +334,52 @@ def _lazy_config(cls) -> None:
250334
if not cls._configured: # pragma: no cover - trivial
251335
cls.configure()
252336

337+
# ---------------- text formatting helpers ----------------
338+
@staticmethod
339+
def _chapter(title: str) -> str:
340+
"""Formats a chapter header with bold magenta text, uppercase,
341+
and padding.
342+
"""
343+
width = 80
344+
symbol = '─'
345+
full_title = f' {title.upper()} '
346+
pad_len = (width - len(full_title)) // 2
347+
padding = symbol * pad_len
348+
line = f'[bold magenta]{padding}{full_title}{padding}[/bold magenta]'
349+
if len(line) < width:
350+
line += symbol
351+
formatted = f'{line}'
352+
from easydiffraction.utils.env import is_notebook as in_jupyter
353+
354+
if not in_jupyter():
355+
formatted = f'\n{formatted}'
356+
return formatted
357+
358+
@staticmethod
359+
def _section(title: str) -> str:
360+
"""Formats a section header with bold green text."""
361+
full_title = f'{title.upper()}'
362+
line = '━' * len(full_title)
363+
formatted = f'[bold green]{full_title}\n{line}[/bold green]'
364+
365+
# Avoid injecting extra newlines; callers can separate sections
366+
return formatted
367+
368+
@staticmethod
369+
def _paragraph(message: str) -> Text:
370+
parts = re.split(r"('.*?')", message)
371+
text = Text()
372+
for part in parts:
373+
if part.startswith("'") and part.endswith("'"):
374+
text.append(part)
375+
else:
376+
text.append(part, style='bold blue')
377+
formatted = f'{text.markup}'
378+
379+
# Paragraphs should not force an extra leading newline; rely on
380+
# caller
381+
return formatted
382+
253383
# ---------------- core routing ----------------
254384
@classmethod
255385
def handle(
@@ -278,33 +408,50 @@ def handle(
278408

279409
# ---------------- convenience API ----------------
280410
@classmethod
281-
def debug(cls, message: str) -> None:
282-
cls.handle(message, level=cls.Level.DEBUG, exc_type=None)
411+
def debug(cls, *messages: str) -> None:
412+
for message in messages:
413+
cls.handle(message, level=cls.Level.DEBUG, exc_type=None)
283414

284415
@classmethod
285-
def info(cls, message: str) -> None:
286-
cls.handle(message, level=cls.Level.INFO, exc_type=None)
416+
def info(cls, *messages: str) -> None:
417+
for message in messages:
418+
cls.handle(message, level=cls.Level.INFO, exc_type=None)
287419

288420
@classmethod
289-
def warning(cls, message: str, exc_type: type[BaseException] | None = None) -> None:
290-
cls.handle(message, level=cls.Level.WARNING, exc_type=exc_type)
421+
def warning(cls, *messages: str, exc_type: type[BaseException] | None = None) -> None:
422+
for message in messages:
423+
cls.handle(message, level=cls.Level.WARNING, exc_type=exc_type)
291424

292425
@classmethod
293-
def error(cls, message: str, exc_type: type[BaseException] = AttributeError) -> None:
294-
if cls._reaction is cls.Reaction.RAISE:
295-
cls.handle(message, level=cls.Level.ERROR, exc_type=exc_type)
296-
elif cls._reaction is cls.Reaction.WARN:
297-
cls.handle(message, level=cls.Level.WARNING, exc_type=UserWarning)
426+
def error(cls, *messages: str, exc_type: type[BaseException] = AttributeError) -> None:
427+
for message in messages:
428+
if cls._reaction is cls.Reaction.RAISE:
429+
cls.handle(message, level=cls.Level.ERROR, exc_type=exc_type)
430+
elif cls._reaction is cls.Reaction.WARN:
431+
cls.handle(message, level=cls.Level.WARNING, exc_type=UserWarning)
298432

299433
@classmethod
300-
def critical(cls, message: str, exc_type: type[BaseException] = RuntimeError) -> None:
301-
cls.handle(message, level=cls.Level.CRITICAL, exc_type=exc_type)
434+
def critical(cls, *messages: str, exc_type: type[BaseException] = RuntimeError) -> None:
435+
for message in messages:
436+
cls.handle(message, level=cls.Level.CRITICAL, exc_type=exc_type)
302437

303438
@classmethod
304439
def exception(cls, message: str) -> None:
305440
"""Log current exception from inside ``except`` block."""
306441
cls._lazy_config()
307442
cls._logger.error(message, exc_info=True)
308443

444+
@classmethod
445+
def paragraph(cls, message: str) -> None:
446+
cls.print(cls._paragraph(message))
447+
448+
@classmethod
449+
def section(cls, message: str) -> None:
450+
cls.print(cls._section(message))
451+
452+
@classmethod
453+
def chapter(cls, message: str) -> None:
454+
cls.info(cls._chapter(message))
455+
309456

310457
log = Logger # ergonomic alias

0 commit comments

Comments
 (0)