"""
This module provides utility functions for printing messages with different levels of importance.
If a message is printed multiple times, it will be displayed as "(xN) message"
where N is the number of times the message has been printed.
The module also includes a `colored()` function that formats text with Python 3.14 style coloring
for file paths, line numbers, function names (in magenta), and exception names (in bold magenta).
All functions have their colored counterparts with a 'c' suffix (e.g., `infoc()`, `debugc()`, etc.)
.. image:: https://raw.githubusercontent.com/Stoupy51/stouputils/refs/heads/main/assets/print_module.gif
:alt: stouputils print examples
"""
# Imports
import os
import sys
import time
from collections.abc import Callable, Iterable, Iterator
from typing import IO, Any, TextIO, cast
# Colors constants
RESET: str = "\033[0m"
RED: str = "\033[91m"
GREEN: str = "\033[92m"
YELLOW: str = "\033[93m"
BLUE: str = "\033[94m"
MAGENTA: str = "\033[95m"
CYAN: str = "\033[96m"
LINE_UP: str = "\033[1A"
BOLD: str = "\033[1m"
# Constants
BAR_FORMAT: str = "{l_bar}{bar}" + MAGENTA + "| {n_fmt}/{total_fmt} [{rate_fmt}{postfix}, {elapsed}<{remaining}]" + RESET
# Enable colors on Windows 10 terminal if applicable
if os.name == "nt":
import subprocess
subprocess.run("color", shell=True)
# Print functions
previous_args_kwards: tuple[Any, Any] = ((), {})
nb_values: int = 1
import_time: float = time.time()
# Colored for loop function
[docs]
def colored_for_loop[T](
iterable: Iterable[T],
desc: str = "Processing",
color: str = MAGENTA,
bar_format: str = BAR_FORMAT,
ascii: bool = False,
smooth_tqdm: bool = True,
**kwargs: Any
) -> Iterator[T]:
""" Function to iterate over a list with a colored TQDM progress bar like the other functions in this module.
Args:
iterable (Iterable): List to iterate over
desc (str): Description of the function execution displayed in the progress bar
color (str): Color of the progress bar (Defaults to MAGENTA)
bar_format (str): Format of the progress bar (Defaults to BAR_FORMAT)
ascii (bool): Whether to use ASCII or Unicode characters for the progress bar (Defaults to False)
smooth_tqdm (bool): Whether to enable smooth progress bar updates by setting miniters=1 and mininterval=0.0 (Defaults to True)
**kwargs: Additional arguments to pass to the TQDM progress bar
Yields:
T: Each item of the iterable
Examples:
>>> for i in colored_for_loop(range(10), desc="Time sleeping loop"):
... time.sleep(0.01)
>>> # Time sleeping loop: 100%|██████████████████| 10/10 [ 95.72it/s, 00:00<00:00]
"""
if bar_format == BAR_FORMAT:
bar_format = bar_format.replace(MAGENTA, color)
desc = color + desc
if smooth_tqdm:
kwargs.setdefault("mininterval", 0.0)
try:
total = len(iterable) # type: ignore
import shutil
width = shutil.get_terminal_size().columns
kwargs.setdefault("miniters", max(1, total // width))
except (TypeError, OSError):
kwargs.setdefault("miniters", 1)
from tqdm.auto import tqdm
yield from tqdm(iterable, desc=desc, bar_format=bar_format, ascii=ascii, **kwargs)
[docs]
def colored(
*values: Any,
file: TextIO | None = None,
**print_kwargs: Any,
) -> None:
""" Print with Python 3.14 style colored formatting.
Dynamically colors text by analyzing each word:
- File paths in magenta
- Numbers in magenta
- Function names (built-in and callable objects) in magenta
- Exception names in bold magenta
Args:
values (Any): Values to print (like the print function)
file (TextIO): File to write the message to (default: sys.stdout)
print_kwargs (dict): Keyword arguments to pass to the print function
Examples:
>>> colored("File '/path/to/file.py', line 42, in function_name") # doctest: +SKIP
>>> colored("KeyboardInterrupt") # doctest: +SKIP
>>> colored("Processing data.csv with 100 items") # doctest: +SKIP
>>> colored("Using print and len functions") # doctest: +SKIP
"""
if file is None:
file = sys.stdout
result: str = format_colored(*values)
print(result, file=file, **print_kwargs)
[docs]
def info(
*values: Any,
color: str = GREEN,
text: str = "INFO ",
prefix: str = "",
file: TextIO | list[TextIO] | None = None,
use_colored: bool = False,
**print_kwargs: Any,
) -> None:
""" Print an information message looking like "[INFO HH:MM:SS] message" in green by default.
Args:
values (Any): Values to print (like the print function)
color (str): Color of the message (default: GREEN)
text (str): Text of the message (default: "INFO ")
prefix (str): Prefix to add to the values
file (TextIO|list[TextIO]): File(s) to write the message to (default: sys.stdout)
use_colored (bool): Whether to use the colored() function to format the message
print_kwargs (dict): Keyword arguments to pass to the print function
"""
# Use stdout if no file is specified
if file is None:
file = sys.stdout
# If file is a list, recursively call info() for each file
if isinstance(file, list):
for f in file:
info(*values, color=color, text=text, prefix=prefix, file=f, use_colored=use_colored, **print_kwargs)
else:
# Build the message with prefix, color, text and timestamp
message: str = f"{prefix}{color}[{text} {current_time()}]"
# If this is a repeated print, add a line up and counter
if is_same_print(*values, color=color, text=text, prefix=prefix, **print_kwargs):
message = f"{LINE_UP}{message} (x{nb_values})"
# Print the message with the values and reset color
if use_colored:
print(message, format_colored(*values).replace(RESET, RESET+color), RESET, file=file, **print_kwargs)
else:
print(message, *values, RESET, file=file, **print_kwargs)
[docs]
def debug(*values: Any, flush: bool = True, **print_kwargs: Any) -> None:
""" Print a debug message looking like "[DEBUG HH:MM:SS] message" in cyan by default. """
if "text" not in print_kwargs:
print_kwargs["text"] = "DEBUG"
if "color" not in print_kwargs:
print_kwargs["color"] = CYAN
info(*values, flush=flush, **print_kwargs)
[docs]
def alt_debug(*values: Any, flush: bool = True, **print_kwargs: Any) -> None:
""" Print a debug message looking like "[DEBUG HH:MM:SS] message" in blue by default. """
if "text" not in print_kwargs:
print_kwargs["text"] = "DEBUG"
if "color" not in print_kwargs:
print_kwargs["color"] = BLUE
info(*values, flush=flush, **print_kwargs)
[docs]
def suggestion(*values: Any, flush: bool = True, **print_kwargs: Any) -> None:
""" Print a suggestion message looking like "[SUGGESTION HH:MM:SS] message" in cyan by default. """
if "text" not in print_kwargs:
print_kwargs["text"] = "SUGGESTION"
if "color" not in print_kwargs:
print_kwargs["color"] = CYAN
info(*values, flush=flush, **print_kwargs)
[docs]
def progress(*values: Any, flush: bool = True, **print_kwargs: Any) -> None:
""" Print a progress message looking like "[PROGRESS HH:MM:SS] message" in magenta by default. """
if "text" not in print_kwargs:
print_kwargs["text"] = "PROGRESS"
if "color" not in print_kwargs:
print_kwargs["color"] = MAGENTA
info(*values, flush=flush, **print_kwargs)
[docs]
def warning(*values: Any, flush: bool = True, **print_kwargs: Any) -> None:
""" Print a warning message looking like "[WARNING HH:MM:SS] message" in yellow by default and in sys.stderr. """
if "file" not in print_kwargs:
print_kwargs["file"] = sys.stderr
if "text" not in print_kwargs:
print_kwargs["text"] = "WARNING"
if "color" not in print_kwargs:
print_kwargs["color"] = YELLOW
info(*values, flush=flush, **print_kwargs)
[docs]
def error(*values: Any, exit: bool = False, flush: bool = True, **print_kwargs: Any) -> None:
""" Print an error message (in sys.stderr and in red by default)
and optionally ask the user to continue or stop the program.
Args:
values (Any): Values to print (like the print function)
exit (bool): Whether to ask the user to continue or stop the program,
false to ignore the error automatically and continue
print_kwargs (dict): Keyword arguments to pass to the print function
"""
file: TextIO = sys.stderr
if "file" in print_kwargs:
if isinstance(print_kwargs["file"], list):
file = cast(TextIO, print_kwargs["file"][0])
else:
file = print_kwargs["file"]
if "text" not in print_kwargs:
print_kwargs["text"] = "ERROR"
if "color" not in print_kwargs:
print_kwargs["color"] = RED
info(*values, flush=flush, **print_kwargs)
if exit:
try:
print("Press enter to ignore error and continue, or 'CTRL+C' to stop the program... ", file=file)
input()
except (KeyboardInterrupt, EOFError):
print(file=file)
sys.exit(1)
[docs]
def whatisit(
*values: Any,
print_function: Callable[..., None] = debug,
flush: bool = True,
max_length: int = 250,
color: str = CYAN,
**print_kwargs: Any,
) -> None:
""" Print the type of each value and the value itself, with its id and length/shape.
The output format is: "type, <id id_number>: (length/shape) value"
Args:
values (Any): Values to print
print_function (Callable): Function to use to print the values (default: debug())
max_length (int): Maximum length of the value string to print (default: 250)
color (str): Color of the message (default: CYAN)
print_kwargs (dict): Keyword arguments to pass to the print function
"""
def _internal(value: Any) -> str:
""" Get the string representation of the value, with length or shape instead of length if shape is available """
# Build metadata parts list
metadata_parts: list[str] = []
# Get the dtype if available
try:
metadata_parts.append(f"dtype: {value.dtype}")
except (AttributeError, TypeError):
pass
# Get the shape or length of the value
try:
metadata_parts.append(f"shape: {value.shape}")
except (AttributeError, TypeError):
try:
metadata_parts.append(f"length: {len(value)}")
except (AttributeError, TypeError):
pass
# Get the min and max if available (Iterable of numbers)
try:
if not isinstance(value, str | bytes | bytearray | dict | int | float):
import numpy as np
mini, maxi = np.min(value), np.max(value) # type: ignore
if mini != maxi:
metadata_parts.append(f"min: {mini}")
metadata_parts.append(f"max: {maxi}")
except (Exception):
pass
# Combine metadata into a single parenthesized string
metadata_str: str = f"({', '.join(metadata_parts)}) " if metadata_parts else ""
# Get the string representation of the value
value = cast(Any, value)
value_str: str = str(value)
if len(value_str) > max_length:
value_str = value_str[:max_length] + "..."
if "\n" in value_str:
value_str = "\n" + value_str # Add a newline before the value if there is a newline in it.
# Return the formatted string
return f"{type(value)}, <id {id(value)}>: {metadata_str}{value_str}"
# Add the color to the message
if "color" not in print_kwargs:
print_kwargs["color"] = color
# Set text to "What is it?" if not already set
if "text" not in print_kwargs:
print_kwargs["text"] = "What is it?"
# Print the values
if len(values) > 1:
print_function("".join(f"\n {_internal(value)}" for value in values), flush=flush, **print_kwargs)
elif len(values) == 1:
print_function(_internal(values[0]), flush=flush, **print_kwargs)
[docs]
def breakpoint(*values: Any, print_function: Callable[..., None] = warning, flush: bool = True, **print_kwargs: Any) -> None:
""" Breakpoint function, pause the program and print the values.
Args:
values (Any): Values to print
print_function (Callable): Function to use to print the values (default: warning())
print_kwargs (dict): Keyword arguments to pass to the print function
"""
if "text" not in print_kwargs:
print_kwargs["text"] = "BREAKPOINT (press Enter)"
file: TextIO = sys.stderr
if "file" in print_kwargs:
if isinstance(print_kwargs["file"], list):
file = cast(TextIO, print_kwargs["file"][0])
else:
file = print_kwargs["file"]
whatisit(*values, print_function=print_function, flush=flush, **print_kwargs)
try:
input()
except (KeyboardInterrupt, EOFError):
print(file=file)
sys.exit(1)
# TeeMultiOutput class to duplicate output to multiple file-like objects
[docs]
class TeeMultiOutput:
""" File-like object that duplicates output to multiple file-like objects.
Args:
*files (IO[Any]): One or more file-like objects that have write and flush methods
strip_colors (bool): Strip ANSI color codes from output sent to non-stdout/stderr files
ascii_only (bool): Replace non-ASCII characters with their ASCII equivalents for non-stdout/stderr files
ignore_lineup (bool): Ignore lines containing LINE_UP escape sequence in non-terminal outputs
Examples:
>>> f = open("logfile.txt", "w")
>>> sys.stdout = TeeMultiOutput(sys.stdout, f)
>>> print("Hello World") # Output goes to both console and file
Hello World
>>> f.close() # TeeMultiOutput will handle any future writes to closed files gracefully
"""
def __init__(
self, *files: IO[Any], strip_colors: bool = True, ascii_only: bool = True, ignore_lineup: bool = True
) -> None:
# Flatten any TeeMultiOutput instances in files
flattened_files: list[IO[Any]] = []
for file in files:
if isinstance(file, TeeMultiOutput):
flattened_files.extend(file.files)
else:
flattened_files.append(file)
self.files: tuple[IO[Any], ...] = tuple(flattened_files)
""" File-like objects to write to """
self.strip_colors: bool = strip_colors
""" Whether to strip ANSI color codes from output sent to non-stdout/stderr files """
self.ascii_only: bool = ascii_only
""" Whether to replace non-ASCII characters with their ASCII equivalents for non-stdout/stderr files """
self.ignore_lineup: bool = ignore_lineup
""" Whether to ignore lines containing LINE_UP escape sequence in non-terminal outputs """
@property
def encoding(self) -> str:
""" Get the encoding of the first file, or "utf-8" as fallback.
Returns:
str: The encoding, ex: "utf-8", "ascii", "latin1", etc.
"""
try:
return self.files[0].encoding # type: ignore
except (IndexError, AttributeError):
return "utf-8"
[docs]
def write(self, obj: str) -> int:
""" Write the object to all files while stripping colors if needed.
Args:
obj (str): String to write
Returns:
int: Number of characters written to the first file
"""
files_to_remove: list[IO[Any]] = []
num_chars_written: int = 0
for i, f in enumerate(self.files):
try:
# Check if file is closed
if hasattr(f, "closed") and f.closed:
files_to_remove.append(f)
continue
# Check if this file is a terminal/console or a regular file
content: str = obj
if not (hasattr(f, "isatty") and f.isatty()):
# Non-terminal files get processed content (stripped colors, ASCII-only, etc.)
# Skip content if it contains LINE_UP and ignore_lineup is True
if self.ignore_lineup and (LINE_UP in content or "\r" in content):
continue
# Strip colors if needed
if self.strip_colors:
content = remove_colors(content)
# Replace Unicode block characters with ASCII equivalents
# Replace other problematic Unicode characters as needed
if self.ascii_only:
content = content.replace('█', '#')
content = ''.join(c if ord(c) < 128 else '?' for c in content)
# Write content to file
if i == 0:
num_chars_written = f.write(content)
else:
f.write(content)
except ValueError:
# ValueError is raised when writing to a closed file
files_to_remove.append(f)
except Exception:
pass
# Remove closed files from the list
if files_to_remove:
self.files = tuple(f for f in self.files if f not in files_to_remove)
return num_chars_written
[docs]
def flush(self) -> None:
""" Flush all files. """
for f in self.files:
try:
f.flush()
except Exception:
pass
[docs]
def fileno(self) -> int:
""" Return the file descriptor of the first file. """
return self.files[0].fileno() if hasattr(self.files[0], "fileno") else 0
# Utility functions
[docs]
def remove_colors(text: str) -> str:
""" Remove the colors from a text """
for color in [RESET, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, LINE_UP]:
text = text.replace(color, "")
return text
[docs]
def is_same_print(*args: Any, **kwargs: Any) -> bool:
""" Checks if the current print call is the same as the previous one. """
global previous_args_kwards, nb_values
try:
if previous_args_kwards == (args, kwargs):
nb_values += 1
return True
except Exception:
# Comparison failed (e.g., comparing DataFrames or other complex objects)
# Use str() for comparison instead
current_str: str = str((args, kwargs))
previous_str: str = str(previous_args_kwards)
if previous_str == current_str:
nb_values += 1
return True
# Else, update previous args and reset counter
previous_args_kwards = (args, kwargs)
nb_values = 1
return False
[docs]
def current_time() -> str:
""" Get the current time as "HH:MM:SS" if less than 24 hours since import, else "YYYY-MM-DD HH:MM:SS" """
# If the import time is more than 24 hours, return the full datetime
if (time.time() - import_time) > (24 * 60 * 60):
return time.strftime("%Y-%m-%d %H:%M:%S")
else:
return time.strftime("%H:%M:%S")
# Convenience colored functions
def infoc(*args: Any, **kwargs: Any) -> None:
return info(*args, use_colored=True, **kwargs)
def debugc(*args: Any, **kwargs: Any) -> None:
return debug(*args, use_colored=True, **kwargs)
def alt_debugc(*args: Any, **kwargs: Any) -> None:
return alt_debug(*args, use_colored=True, **kwargs)
def warningc(*args: Any, **kwargs: Any) -> None:
return warning(*args, use_colored=True, **kwargs)
def errorc(*args: Any, **kwargs: Any) -> None:
return error(*args, use_colored=True, **kwargs)
def progressc(*args: Any, **kwargs: Any) -> None:
return progress(*args, use_colored=True, **kwargs)
def suggestionc(*args: Any, **kwargs: Any) -> None:
return suggestion(*args, use_colored=True, **kwargs)
def whatisitc(*args: Any, **kwargs: Any) -> None:
return whatisit(*args, use_colored=True, **kwargs)
def breakpointc(*args: Any, **kwargs: Any) -> None:
return breakpoint(*args, use_colored=True, **kwargs)
# Test the print functions
if __name__ == "__main__":
info("Hello", "World")
time.sleep(0.5)
info("Hello", "World")
time.sleep(0.5)
info("Hello", "World")
time.sleep(0.5)
info("Not Hello World !")
time.sleep(0.5)
info("Hello", "World")
time.sleep(0.5)
info("Hello", "World")
# All remaining print functions
alt_debug("Hello", "World")
debug("Hello", "World")
suggestion("Hello", "World")
progress("Hello", "World")
warning("Hello", "World")
error("Hello", "World", exit=False)
whatisit("Hello")
whatisit("Hello", "World")
# Test whatisit with different types
import numpy as np
print()
whatisit(
123,
"Hello World",
[1, 2, 3, 4, 5],
np.array([[1, 2, 3], [4, 5, 6]]),
{"a": 1, "b": 2},
)