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
2 changes: 1 addition & 1 deletion safety/auth/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ def handle_timeout(self) -> None:
headless = kwargs.get("headless", False)
initial_state = kwargs.get("initial_state", None)
ctx = kwargs.get("ctx", None)
message = "Copy and paste this URL into your browser:\n⚠️ Ensure there are no extra spaces, especially at line breaks, as they may break the link."
message = "Copy and paste this URL into your browser:\n:icon_warning: Ensure there are no extra spaces, especially at line breaks, as they may break the link."

if not headless:
# Start a threaded HTTP server to handle the callback
Expand Down
126 changes: 120 additions & 6 deletions safety/console.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,118 @@
from functools import lru_cache
import logging
import os
import sys
from typing import TYPE_CHECKING, List, Dict, Any, Optional, Union
from rich.console import Console
from rich.theme import Theme
from safety.emoji import load_emoji


if TYPE_CHECKING:
from rich.console import HighlighterType, JustifyMethod, OverflowMethod
from rich.style import Style
from rich.text import Text


LOG = logging.getLogger(__name__)


@lru_cache()
def should_use_ascii():
"""
Check if we should use ASCII alternatives for emojis
"""
encoding = getattr(sys.stdout, "encoding", "").lower()

if encoding in {"utf-8", "utf8", "cp65001", "utf-8-sig"}:
return False

return True


def get_spinner_animation() -> List[str]:
"""
Get the spinner animation based on the encoding
"""
if should_use_ascii():
spinner = [
"[ ]",
"[= ]",
"[== ]",
"[=== ]",
"[====]",
"[ ===]",
"[ ==]",
"[ =]",
]
else:
spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
return spinner


def replace_non_ascii_chars(text: str):
"""
Replace non-ascii characters with ascii alternatives
"""
CHARS_MAP = {
"━": "-",
"’": "'",
}

for char, replacement in CHARS_MAP.items():
text = text.replace(char, replacement)

try:
text.encode("ascii")
except UnicodeEncodeError:
LOG.warning("No handled non-ascii characters detected, encoding with replace")
text = text.encode("ascii", "replace").decode("ascii")

return text


class SafeConsole(Console):
"""
Console subclass that handles emoji encoding issues by detecting
problematic encoding environments and replacing emojis with ASCII alternatives.
Uses string replacement for custom emoji namespace to avoid private API usage.
"""

def render_str(
self,
text: str,
*,
style: Union[str, "Style"] = "",
justify: Optional["JustifyMethod"] = None,
overflow: Optional["OverflowMethod"] = None,
emoji: Optional[bool] = None,
markup: Optional[bool] = None,
highlight: Optional[bool] = None,
highlighter: Optional["HighlighterType"] = None,
) -> "Text":
"""
Override render_str to pre-process our custom emojis before Rich handles the text.
"""

use_ascii = should_use_ascii()
text = load_emoji(text, use_ascii=use_ascii)

if use_ascii:
text = replace_non_ascii_chars(text)

# Let Rich handle everything else normally
return super().render_str(
text,
style=style,
justify=justify,
overflow=overflow,
emoji=emoji,
markup=markup,
highlight=highlight,
highlighter=highlighter,
)


SAFETY_THEME = {
"file_title": "bold default on default",
"dep_name": "bold yellow on default",
Expand All @@ -23,13 +131,19 @@
"vulns_found_number": "red on default",
}

non_interactive = os.getenv('NON_INTERACTIVE') == '1'

console_kwargs = {"theme": Theme(SAFETY_THEME, inherit=False)}
non_interactive = os.getenv("NON_INTERACTIVE") == "1"

if non_interactive:
LOG.info("NON_INTERACTIVE environment variable is set, forcing non-interactive mode")
console_kwargs.update({"force_terminal": True, "force_interactive": False})
console_kwargs: Dict[str, Any] = {
"theme": Theme(SAFETY_THEME, inherit=False),
"emoji": not should_use_ascii(),
}

if non_interactive:
LOG.info(
"NON_INTERACTIVE environment variable is set, forcing non-interactive mode"
)
console_kwargs["force_terminal"] = True
console_kwargs["force_interactive"] = False

main_console = Console(**console_kwargs)
main_console = SafeConsole(**console_kwargs)
104 changes: 104 additions & 0 deletions safety/emoji.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Custom emoji namespace mapping
import re
from typing import Match


CUSTOM_EMOJI_MAP = {
"icon_check": "✓",
"icon_warning": "⚠️",
"icon_info": "ℹ️",
}

# ASCII fallback mapping for problematic environments
ASCII_FALLBACK_MAP = {
"icon_check": "+",
"icon_warning": "!",
"icon_info": "i",
"white_heavy_check_mark": "++",
"white_check_mark": "+",
"check_mark": "+",
"heavy_check_mark": "+",
"shield": "[SHIELD]",
"x": "X",
"lock": "[LOCK]",
"key": "[KEY]",
"pencil": "[EDIT]",
"arrow_up": "^",
"stop_sign": "[STOP]",
"warning": "!",
"locked": "[LOCK]",
"pushpin": "[PIN]",
"magnifying_glass_tilted_left": "[SCAN]",
"fire": "[CRIT]",
"yellow_circle": "[HIGH]",
"sparkles": "*",
"mag_right": "[VIEW]",
"link": "->",
"light_bulb": "[TIP]",
"trophy": "[DONE]",
"rocket": ">>",
"busts_in_silhouette": "[TEAM]",
"floppy_disk": "[SAVE]",
"heavy_plus_sign": "[ADD]",
"books": "[DOCS]",
"speech_balloon": "[HELP]",
}

# Pre-compiled regex for emoji processing (Rich-style)
CUSTOM_EMOJI_PATTERN = re.compile(r"(:icon_\w+:)")


def process_custom_emojis(text: str, use_ascii: bool = False) -> str:
"""
Pre-process our custom emoji namespace before Rich handles the text.
This only handles our custom :icon_*: emojis.
"""
if not isinstance(text, str) or ":icon_" not in text:
return text

def replace_custom_emoji(match: Match[str]) -> str:
emoji_code = match.group(1) # :icon_check:
emoji_name = emoji_code[1:-1] # icon_check

# If we should use ASCII, use the fallback
if use_ascii:
return ASCII_FALLBACK_MAP.get(emoji_name, emoji_code)

return CUSTOM_EMOJI_MAP.get(emoji_name, emoji_code)

return CUSTOM_EMOJI_PATTERN.sub(replace_custom_emoji, text)


def process_rich_emojis_fallback(text: str) -> str:
"""
Replace Rich emoji codes with ASCII alternatives when in problematic environments.
"""
# Simple pattern to match Rich emoji codes like :emoji_name:
emoji_pattern = re.compile(r":([a-zA-Z0-9_]+):")

def replace_with_ascii(match: Match[str]) -> str:
emoji_name = match.group(1)
# Check if we have an ASCII fallback
ascii_replacement = ASCII_FALLBACK_MAP.get(emoji_name, None)
if ascii_replacement:
return ascii_replacement

# Otherwise keep the original
return match.group(0)

return emoji_pattern.sub(replace_with_ascii, text)


def load_emoji(text: str, use_ascii: bool = False) -> str:
"""
Load emoji from text if emoji is present.
"""

# Pre-process our custom emojis
text = process_custom_emojis(text, use_ascii)

# If we need ASCII fallbacks, also process Rich emoji codes
if use_ascii:
text = process_rich_emojis_fallback(text)

return text
Loading
Loading