diff --git a/icecream/icecream.py b/icecream/icecream.py index 45a39ca..95ad181 100644 --- a/icecream/icecream.py +++ b/icecream/icecream.py @@ -15,6 +15,7 @@ import enum import inspect import pprint +import re import sys import time from types import FrameType, TracebackType @@ -295,11 +296,57 @@ def argumentToString(obj: object) -> str: return s +# Control characters (0x00-0x1F, 0x7F) and invisible Unicode pattern +_CONTROL_CHAR_PATTERN = re.compile(r'[\x00-\x1f\x7f\u00a0\u200b-\u200f\u202a-\u202e\ufeff]') + +# Pattern for control chars excluding newline +_OTHER_CONTROL_PATTERN = re.compile(r'[\x00-\x09\x0b-\x1f\x7f\u00a0\u200b-\u200f\u202a-\u202e\ufeff]') + +# Map of control characters to their preferred escape sequences +_CONTROL_CHAR_MAP = { + '\x00': '\\x00', # Null byte + '\n': '\\n', # Newline + '\t': '\\t', # Tab + '\r': '\\r', # Carriage return + '\x07': '\\a', # Bell + '\x08': '\\b', # Backspace + '\x0c': '\\f', # Form feed + '\x0b': '\\v', # Vertical tab +} + +# Map of invisible Unicode characters to their escape sequences +_INVISIBLE_UNICODE_MAP = { + '\u200b': '\\u200b', # Zero-width space + '\u00a0': '\\u00a0', # Non-breaking space + '\u200c': '\\u200c', # Zero-width non-joiner + '\u200d': '\\u200d', # Zero-width joiner + '\u200e': '\\u200e', # Left-to-right mark + '\u200f': '\\u200f', # Right-to-left mark +} + + @argumentToString.register(str) def _(obj: str) -> str: + def replace_char(match): + char = match.group(0) + if char in _CONTROL_CHAR_MAP: + return _CONTROL_CHAR_MAP[char] + if char in _INVISIBLE_UNICODE_MAP: + return _INVISIBLE_UNICODE_MAP[char] + code = ord(char) + if code < 0x100: + return f'\\x{code:02x}' + return f'\\u{code:04x}' + + # Escape backslashes first so the escapes we add below aren't doubled. + escaped = obj.replace('\\', '\\\\') + if '\n' in obj: - return "'''" + obj + "'''" - return "'" + obj.replace('\\', '\\\\') + "'" + escaped = _OTHER_CONTROL_PATTERN.sub(replace_char, escaped) + return "'''" + escaped + "'''" + + escaped = _CONTROL_CHAR_PATTERN.sub(replace_char, escaped) + return "'" + escaped + "'" class IceCreamDebugger: diff --git a/tests/test_icecream.py b/tests/test_icecream.py index 342c56d..c51fe35 100644 --- a/tests/test_icecream.py +++ b/tests/test_icecream.py @@ -757,6 +757,98 @@ def test_sympy_solve_result_does_not_crash(self): self.assertIn("ic|", s) # Don’t assert exact text; just ensure something printed. self.assertTrue(len(s) > 0) + + def test_control_characters_escaped(self): + """Test that control characters are properly escaped in output.""" + + test_null = "hello\x00world" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_null) + output = err.getvalue().strip() + self.assertIn(r'\x00', output) + self.assertIn(r"test_null: 'hello\x00world'", output) + + test_tab = "hello\tworld" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_tab) + output = err.getvalue().strip() + self.assertIn(r'\t', output) + self.assertIn(r"test_tab: 'hello\tworld'", output) + + test_bs = "hello\bworld" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_bs) + output = err.getvalue().strip() + self.assertIn(r'\b', output) + self.assertIn(r"test_bs: 'hello\bworld'", output) + + def test_invisible_unicode_escaped(self): + """Test that invisible Unicode characters are properly escaped.""" + + test_zwsp = "hello\u200bworld" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_zwsp) + output = err.getvalue().strip() + self.assertIn(r'\u200b', output) + self.assertIn(r"test_zwsp: 'hello\u200bworld'", output) + + test_nbsp = "hello\u00a0world" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_nbsp) + output = err.getvalue().strip() + self.assertIn(r'\u00a0', output) + self.assertIn(r"test_nbsp: 'hello\u00a0world'", output) + + def test_newline_only_strings(self): + """Test strings with only newlines (baseline compatibility).""" + + test_baseline1 = "line\nline" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_baseline1) + output = err.getvalue().strip() + self.assertIn("'''line\n line'''", output) + + test_baseline2 = "line1\nline2" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_baseline2) + output = err.getvalue().strip() + self.assertIn("'''line1\n line2'''", output) + + test_other_newline = "first\nsecond\nthird" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_other_newline) + output = err.getvalue().strip() + self.assertIn("'''first\n second\n third'''", output) + + def test_backslash_escaping(self): + """Test that backslashes are properly escaped.""" + + test_backslash = "path\\to\\file" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_backslash) + output = err.getvalue().strip() + self.assertIn(r'path\\to\\file', output) + + test_bs_ctrl = "path\\to\tfile" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_bs_ctrl) + output = err.getvalue().strip() + self.assertIn(r'path\\to\tfile', output) + + def test_normal_strings_unchanged(self): + """Test that normal strings without control chars work as expected.""" + + test_normal = "hello world" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_normal) + output = err.getvalue().strip() + self.assertIn("test_normal: 'hello world'", output) + + test_unicode = "hello δΈ–η•Œ 🌍" + with disable_coloring(), capture_standard_streams() as (_, err): + ic(test_unicode) + output = err.getvalue().strip() + self.assertIn("test_unicode: 'hello δΈ–η•Œ 🌍'", output) def test_no_color_disables_coloring(self): originalNoColor = ic.noColor