Skip to content

Commit 293bccb

Browse files
fix: issues with encoding in all the outputs (#757)
1 parent 0abd016 commit 293bccb

File tree

11 files changed

+565
-202
lines changed

11 files changed

+565
-202
lines changed

safety/auth/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ def handle_timeout(self) -> None:
247247
headless = kwargs.get("headless", False)
248248
initial_state = kwargs.get("initial_state", None)
249249
ctx = kwargs.get("ctx", None)
250-
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."
250+
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."
251251

252252
if not headless:
253253
# Start a threaded HTTP server to handle the callback

safety/console.py

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,118 @@
1+
from functools import lru_cache
12
import logging
23
import os
4+
import sys
5+
from typing import TYPE_CHECKING, List, Dict, Any, Optional, Union
36
from rich.console import Console
47
from rich.theme import Theme
8+
from safety.emoji import load_emoji
9+
10+
11+
if TYPE_CHECKING:
12+
from rich.console import HighlighterType, JustifyMethod, OverflowMethod
13+
from rich.style import Style
14+
from rich.text import Text
15+
516

617
LOG = logging.getLogger(__name__)
718

19+
20+
@lru_cache()
21+
def should_use_ascii():
22+
"""
23+
Check if we should use ASCII alternatives for emojis
24+
"""
25+
encoding = getattr(sys.stdout, "encoding", "").lower()
26+
27+
if encoding in {"utf-8", "utf8", "cp65001", "utf-8-sig"}:
28+
return False
29+
30+
return True
31+
32+
33+
def get_spinner_animation() -> List[str]:
34+
"""
35+
Get the spinner animation based on the encoding
36+
"""
37+
if should_use_ascii():
38+
spinner = [
39+
"[ ]",
40+
"[= ]",
41+
"[== ]",
42+
"[=== ]",
43+
"[====]",
44+
"[ ===]",
45+
"[ ==]",
46+
"[ =]",
47+
]
48+
else:
49+
spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
50+
return spinner
51+
52+
53+
def replace_non_ascii_chars(text: str):
54+
"""
55+
Replace non-ascii characters with ascii alternatives
56+
"""
57+
CHARS_MAP = {
58+
"━": "-",
59+
"’": "'",
60+
}
61+
62+
for char, replacement in CHARS_MAP.items():
63+
text = text.replace(char, replacement)
64+
65+
try:
66+
text.encode("ascii")
67+
except UnicodeEncodeError:
68+
LOG.warning("No handled non-ascii characters detected, encoding with replace")
69+
text = text.encode("ascii", "replace").decode("ascii")
70+
71+
return text
72+
73+
74+
class SafeConsole(Console):
75+
"""
76+
Console subclass that handles emoji encoding issues by detecting
77+
problematic encoding environments and replacing emojis with ASCII alternatives.
78+
Uses string replacement for custom emoji namespace to avoid private API usage.
79+
"""
80+
81+
def render_str(
82+
self,
83+
text: str,
84+
*,
85+
style: Union[str, "Style"] = "",
86+
justify: Optional["JustifyMethod"] = None,
87+
overflow: Optional["OverflowMethod"] = None,
88+
emoji: Optional[bool] = None,
89+
markup: Optional[bool] = None,
90+
highlight: Optional[bool] = None,
91+
highlighter: Optional["HighlighterType"] = None,
92+
) -> "Text":
93+
"""
94+
Override render_str to pre-process our custom emojis before Rich handles the text.
95+
"""
96+
97+
use_ascii = should_use_ascii()
98+
text = load_emoji(text, use_ascii=use_ascii)
99+
100+
if use_ascii:
101+
text = replace_non_ascii_chars(text)
102+
103+
# Let Rich handle everything else normally
104+
return super().render_str(
105+
text,
106+
style=style,
107+
justify=justify,
108+
overflow=overflow,
109+
emoji=emoji,
110+
markup=markup,
111+
highlight=highlight,
112+
highlighter=highlighter,
113+
)
114+
115+
8116
SAFETY_THEME = {
9117
"file_title": "bold default on default",
10118
"dep_name": "bold yellow on default",
@@ -23,13 +131,19 @@
23131
"vulns_found_number": "red on default",
24132
}
25133

26-
non_interactive = os.getenv('NON_INTERACTIVE') == '1'
27134

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

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

142+
if non_interactive:
143+
LOG.info(
144+
"NON_INTERACTIVE environment variable is set, forcing non-interactive mode"
145+
)
146+
console_kwargs["force_terminal"] = True
147+
console_kwargs["force_interactive"] = False
34148

35-
main_console = Console(**console_kwargs)
149+
main_console = SafeConsole(**console_kwargs)

safety/emoji.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Custom emoji namespace mapping
2+
import re
3+
from typing import Match
4+
5+
6+
CUSTOM_EMOJI_MAP = {
7+
"icon_check": "✓",
8+
"icon_warning": "⚠️",
9+
"icon_info": "ℹ️",
10+
}
11+
12+
# ASCII fallback mapping for problematic environments
13+
ASCII_FALLBACK_MAP = {
14+
"icon_check": "+",
15+
"icon_warning": "!",
16+
"icon_info": "i",
17+
"white_heavy_check_mark": "++",
18+
"white_check_mark": "+",
19+
"check_mark": "+",
20+
"heavy_check_mark": "+",
21+
"shield": "[SHIELD]",
22+
"x": "X",
23+
"lock": "[LOCK]",
24+
"key": "[KEY]",
25+
"pencil": "[EDIT]",
26+
"arrow_up": "^",
27+
"stop_sign": "[STOP]",
28+
"warning": "!",
29+
"locked": "[LOCK]",
30+
"pushpin": "[PIN]",
31+
"magnifying_glass_tilted_left": "[SCAN]",
32+
"fire": "[CRIT]",
33+
"yellow_circle": "[HIGH]",
34+
"sparkles": "*",
35+
"mag_right": "[VIEW]",
36+
"link": "->",
37+
"light_bulb": "[TIP]",
38+
"trophy": "[DONE]",
39+
"rocket": ">>",
40+
"busts_in_silhouette": "[TEAM]",
41+
"floppy_disk": "[SAVE]",
42+
"heavy_plus_sign": "[ADD]",
43+
"books": "[DOCS]",
44+
"speech_balloon": "[HELP]",
45+
}
46+
47+
# Pre-compiled regex for emoji processing (Rich-style)
48+
CUSTOM_EMOJI_PATTERN = re.compile(r"(:icon_\w+:)")
49+
50+
51+
def process_custom_emojis(text: str, use_ascii: bool = False) -> str:
52+
"""
53+
Pre-process our custom emoji namespace before Rich handles the text.
54+
This only handles our custom :icon_*: emojis.
55+
"""
56+
if not isinstance(text, str) or ":icon_" not in text:
57+
return text
58+
59+
def replace_custom_emoji(match: Match[str]) -> str:
60+
emoji_code = match.group(1) # :icon_check:
61+
emoji_name = emoji_code[1:-1] # icon_check
62+
63+
# If we should use ASCII, use the fallback
64+
if use_ascii:
65+
return ASCII_FALLBACK_MAP.get(emoji_name, emoji_code)
66+
67+
return CUSTOM_EMOJI_MAP.get(emoji_name, emoji_code)
68+
69+
return CUSTOM_EMOJI_PATTERN.sub(replace_custom_emoji, text)
70+
71+
72+
def process_rich_emojis_fallback(text: str) -> str:
73+
"""
74+
Replace Rich emoji codes with ASCII alternatives when in problematic environments.
75+
"""
76+
# Simple pattern to match Rich emoji codes like :emoji_name:
77+
emoji_pattern = re.compile(r":([a-zA-Z0-9_]+):")
78+
79+
def replace_with_ascii(match: Match[str]) -> str:
80+
emoji_name = match.group(1)
81+
# Check if we have an ASCII fallback
82+
ascii_replacement = ASCII_FALLBACK_MAP.get(emoji_name, None)
83+
if ascii_replacement:
84+
return ascii_replacement
85+
86+
# Otherwise keep the original
87+
return match.group(0)
88+
89+
return emoji_pattern.sub(replace_with_ascii, text)
90+
91+
92+
def load_emoji(text: str, use_ascii: bool = False) -> str:
93+
"""
94+
Load emoji from text if emoji is present.
95+
"""
96+
97+
# Pre-process our custom emojis
98+
text = process_custom_emojis(text, use_ascii)
99+
100+
# If we need ASCII fallbacks, also process Rich emoji codes
101+
if use_ascii:
102+
text = process_rich_emojis_fallback(text)
103+
104+
return text

0 commit comments

Comments
 (0)