Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 78 additions & 7 deletions src/usethis/_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import codecs
import functools
import sys
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal

from rich.console import Console

Expand Down Expand Up @@ -52,14 +52,16 @@ def tick_print(msg: str | Exception) -> None:
or usethis_config.alert_only
or usethis_config.instruct_only
):
console.print(f"✔ {msg}", style="green")
icon = _get_icon("tick")
console.print(f"{icon} {msg}", style="green")


def instruct_print(msg: str | Exception) -> None:
msg = str(msg)

if not (usethis_config.quiet or usethis_config.alert_only):
console.print(f"☐ {msg}", style="red")
icon = _get_icon("instruct")
console.print(f"{icon} {msg}", style="red")


def how_print(msg: str | Exception) -> None:
Expand All @@ -70,7 +72,8 @@ def how_print(msg: str | Exception) -> None:
or usethis_config.alert_only
or usethis_config.instruct_only
):
console.print(f"☐ {msg}", style="red")
icon = _get_icon("how")
console.print(f"{icon} {msg}", style="red")


def info_print(msg: str | Exception, temporary: bool = False) -> None:
Expand All @@ -81,18 +84,20 @@ def info_print(msg: str | Exception, temporary: bool = False) -> None:
or usethis_config.alert_only
or usethis_config.instruct_only
):
icon = _get_icon("info")
if temporary:
end = "\r"
else:
end = "\n"
console.print(f" {msg}", style="blue", end=end) # noqa: RUF001
console.print(f"{icon} {msg}", style="blue", end=end)


def err_print(msg: str | Exception) -> None:
msg = str(msg)

if not usethis_config.quiet:
err_console.print(f"✗ {msg}", style="red")
icon = _get_icon("error")
err_console.print(f"{icon} {msg}", style="red")


def warn_print(msg: str | Exception) -> None:
Expand All @@ -104,4 +109,70 @@ def warn_print(msg: str | Exception) -> None:
@functools.cache
def _cached_warn_print(msg: str) -> None:
if not usethis_config.quiet:
console.print(f"⚠ {msg}", style="yellow")
icon = _get_icon("warning")
console.print(f"{icon} {msg}", style="yellow")


# Icon fallback system for terminals with varying Unicode support
IconType = Literal["tick", "instruct", "how", "info", "error", "warning"]

_ICON_FALLBACKS: dict[IconType, tuple[str, str, str]] = {
# Format: (unicode, universal, text) # noqa: ERA001
# Text format uses escaped brackets to avoid Rich markup interpretation
"tick": ("✔", "√", "\\[ok]"),
"instruct": ("☐", "□", "\\[todo]"),
"how": ("☐", "□", "\\[todo]"),
"info": ("ℹ", "i", "\\[info]"), # noqa: RUF001
"error": ("✗", "×", "\\[error]"), # noqa: RUF001
"warning": ("⚠", "!", "\\[warning]"),
}


@functools.cache
def get_icon_mode() -> Literal["unicode", "universal", "text"]:
"""Detect terminal's icon support level.

Tries to encode icons and returns the first level that works.
Cached for performance.
"""
encoding = _get_stdout_encoding()

# Try Unicode (utf-8)
if encoding.lower() in ("utf-8", "utf8"):
return "unicode"

# Try Universal (cp437 and other common encodings)
try:
"✔".encode(encoding)
return "unicode"
except (UnicodeEncodeError, LookupError, AttributeError):
pass

try:
"√".encode(encoding)
return "universal"
except (UnicodeEncodeError, LookupError, AttributeError):
pass

# Final fallback to text
return "text"


def _get_icon(icon_type: IconType) -> str:
"""Get the appropriate icon based on terminal capabilities.

Uses cached terminal detection for performance.
"""
fallbacks = _ICON_FALLBACKS[icon_type]
mode = get_icon_mode()

if mode == "unicode":
return fallbacks[0]
elif mode == "universal":
return fallbacks[1]
else:
return fallbacks[2]


def _get_stdout_encoding() -> str:
return getattr(sys.stdout, "encoding", None) or "utf-8"
3 changes: 2 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest

from usethis._config import usethis_config
from usethis._console import _cached_warn_print
from usethis._console import _cached_warn_print, get_icon_mode
from usethis._integrations.backend.uv.call import call_subprocess, call_uv_subprocess
from usethis._integrations.file.pyproject_toml.io_ import PyprojectTOMLManager
from usethis._test import change_cwd, is_offline
Expand All @@ -24,6 +24,7 @@ def clear_functools_caches():
"""Fixture to clear functools.caches before each test."""

_cached_warn_print.cache_clear()
get_icon_mode.cache_clear()
_importlinter_warn_no_packages_found.cache_clear()


Expand Down
136 changes: 136 additions & 0 deletions tests/usethis/test_console.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import sys
from unittest.mock import Mock

import pytest
from rich.table import Table

from usethis._config import usethis_config
from usethis._console import (
_get_icon,
_get_stdout_encoding,
err_print,
get_icon_mode,
how_print,
info_print,
instruct_print,
Expand Down Expand Up @@ -186,3 +192,133 @@ def test_cached_exception(self, capfd: pytest.CaptureFixture[str]) -> None:
out, err = capfd.readouterr()
assert not err
assert out == "⚠ Hello\n"


class TestGetIconMode:
def test_unicode_support(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange
monkeypatch.setattr("usethis._console._get_stdout_encoding", lambda: "utf-8")
get_icon_mode.cache_clear()

# Act
mode = get_icon_mode()

# Assert
assert mode == "unicode"

def test_universal_support(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange - use cp437 which supports universal but not unicode icons
monkeypatch.setattr("usethis._console._get_stdout_encoding", lambda: "cp437")
get_icon_mode.cache_clear()

# Act
mode = get_icon_mode()

# Assert
assert mode == "universal"

def test_text_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange
monkeypatch.setattr("usethis._console._get_stdout_encoding", lambda: "ascii")
get_icon_mode.cache_clear()

# Act
mode = get_icon_mode()

# Assert
assert mode == "text"

def test_no_encoding_defaults_utf8(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange - _get_encoding returns utf-8 when stdout.encoding is None
mock_stdout = Mock()
mock_stdout.encoding = None
monkeypatch.setattr(sys, "stdout", mock_stdout)
get_icon_mode.cache_clear()

# Act
encoding = _get_stdout_encoding()
mode = get_icon_mode()

# Assert
assert encoding == "utf-8"
assert mode == "unicode"


class TestGetIcon:
def test_unicode_mode_returns_unicode_icons(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange
monkeypatch.setattr("usethis._console._get_stdout_encoding", lambda: "utf-8")
get_icon_mode.cache_clear()

# Act & Assert
assert _get_icon("tick") == "✔"
assert _get_icon("instruct") == "☐"
assert _get_icon("how") == "☐"
assert _get_icon("info") == "ℹ" # noqa: RUF001
assert _get_icon("error") == "✗"
assert _get_icon("warning") == "⚠"

def test_universal_mode_returns_universal_icons(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange
monkeypatch.setattr("usethis._console._get_stdout_encoding", lambda: "cp437")
get_icon_mode.cache_clear()

# Act & Assert
assert _get_icon("tick") == "√"
assert _get_icon("instruct") == "□"
assert _get_icon("how") == "□"
assert _get_icon("info") == "i"
assert _get_icon("error") == "×" # noqa: RUF001
assert _get_icon("warning") == "!"

def test_text_mode_returns_text_labels(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange
monkeypatch.setattr("usethis._console._get_stdout_encoding", lambda: "ascii")
get_icon_mode.cache_clear()

# Act & Assert
# Note: Icons have escaped brackets for Rich console
assert _get_icon("tick") == "\\[ok]"
assert _get_icon("instruct") == "\\[todo]"
assert _get_icon("how") == "\\[todo]"
assert _get_icon("info") == "\\[info]"
assert _get_icon("error") == "\\[error]"
assert _get_icon("warning") == "\\[warning]"


class TestIconFallbackIntegration:
"""Test that print functions work with different icon modes."""

def test_tick_print_with_text_mode(
self, capfd: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange
monkeypatch.setattr("usethis._console._get_stdout_encoding", lambda: "ascii")
get_icon_mode.cache_clear()

# Act
tick_print("Hello")

# Assert
out, _ = capfd.readouterr()
assert "[ok] Hello\n" in out

def test_warn_print_with_universal_mode(
self, capfd: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange
monkeypatch.setattr("usethis._console._get_stdout_encoding", lambda: "cp437")
get_icon_mode.cache_clear()

# Act
warn_print("Warning message")

# Assert
out, _ = capfd.readouterr()
assert "! Warning message\n" in out