fix(python-sdk): port current JS stripAnsi regex to strip_ansi_escape_codes (#1545)
## Summary The Python SDK's `strip_ansi_escape_codes` (used to clean template build log messages) still used the old ansi-regex pattern, while the JS SDK's `stripAnsi` was rewritten in #895. This ports the current JS regex to Python so both SDKs clean logs identically: OSC sequences (hyperlinks, window titles) are matched non-greedily up to the first string terminator — including content spanning newlines — and CSI sequences are stripped without requiring a terminator. Following review feedback, both implementations now also strip the remaining ECMA-48 string controls — DCS (Sixel, tmux passthrough), SOS, PM, and APC — through their string terminator, so control payloads don't leak into cleaned logs. This goes beyond upstream `chalk/ansi-regex`, click, and Rich, none of which fully strip DCS payloads, and restores what the old Python pattern handled. Also mirrors the Python test suite into the JS SDK (which previously had no `stripAnsi` tests) — 20 identical cases per side — and verified byte-for-byte identical output between the two implementations on all of them. Includes a patch changeset for `e2b` and `@e2b/python-sdk`. ## Example Log messages that previously leaked OSC or DCS sequences into template build output are now cleaned: ```python from e2b.template.utils import strip_ansi_escape_codes strip_ansi_escape_codes("\x1b]8;;https://e2b.dev\x07E2B\x1b]8;;\x07") # "E2B" strip_ansi_escape_codes("\x1b]0;title\nstill title\x07done") # "done" strip_ansi_escape_codes("\x1b[38:2::255:0:0mRED\x1b[0m") # "RED" strip_ansi_escape_codes("\x1bPq#0;2;0;0;0~~@@\x1b\\image") # "image" (Sixel DCS) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"e2b": patch
|
||||
"@e2b/python-sdk": patch
|
||||
---
|
||||
|
||||
Align ANSI stripping of template build log messages across both SDKs. The Python SDK's `strip_ansi_escape_codes` now ports the JS SDK's `stripAnsi` regex: OSC sequences (hyperlinks, window titles) are matched non-greedily up to the first string terminator — including sequences spanning newlines — and CSI sequences are stripped without requiring a terminator. Both implementations additionally strip the remaining ECMA-48 string controls (DCS/Sixel, SOS, PM, APC) through their string terminator so control payloads no longer leak into cleaned logs.
|
||||
@@ -86,13 +86,13 @@ export async function dynamicImport<T>(module: string): Promise<T> {
|
||||
function ansiRegex({ onlyFirst = false } = {}) {
|
||||
// Valid string terminator sequences are BEL, ESC\, and 0x9c
|
||||
const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)'
|
||||
// OSC sequences only: ESC ] ... ST (non-greedy until the first ST)
|
||||
const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`
|
||||
// String controls (OSC, DCS, SOS, PM, APC): ESC ]/P/X/^/_ ... ST (non-greedy until the first ST)
|
||||
const strings = `(?:\\u001B[\\]PX^_][\\s\\S]*?${ST})`
|
||||
// CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte
|
||||
const csi =
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]'
|
||||
|
||||
const pattern = `${osc}|${csi}`
|
||||
const pattern = `${strings}|${csi}`
|
||||
|
||||
return new RegExp(pattern, onlyFirst ? undefined : 'g')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import { stripAnsi } from '../src/utils'
|
||||
|
||||
// Mirrors packages/python-sdk/tests/shared/template/utils/test_strip_ansi_escape_codes.py
|
||||
|
||||
test('strips basic SGR color', () => {
|
||||
expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red')
|
||||
})
|
||||
|
||||
test('strips semicolon-separated params', () => {
|
||||
expect(stripAnsi('\x1b[1;31;42mhi\x1b[0m')).toBe('hi')
|
||||
})
|
||||
|
||||
test('strips semicolon 256-color', () => {
|
||||
expect(stripAnsi('\x1b[38;5;82mX\x1b[0m')).toBe('X')
|
||||
})
|
||||
|
||||
test('strips colon 256-color', () => {
|
||||
expect(stripAnsi('\x1b[38:5:82mX\x1b[0m')).toBe('X')
|
||||
})
|
||||
|
||||
test('strips colon truecolor', () => {
|
||||
expect(stripAnsi('\x1b[38:2::255:0:0mRED\x1b[0m')).toBe('RED')
|
||||
})
|
||||
|
||||
test('strips colon curly underline', () => {
|
||||
expect(stripAnsi('\x1b[4:3mX\x1b[0m')).toBe('X')
|
||||
})
|
||||
|
||||
test('leaves plain text unchanged', () => {
|
||||
expect(stripAnsi('no escape codes here')).toBe('no escape codes here')
|
||||
})
|
||||
|
||||
test('strips OSC hyperlink', () => {
|
||||
expect(stripAnsi('\x1b]8;;https://e2b.dev\x07E2B\x1b]8;;\x07')).toBe('E2B')
|
||||
})
|
||||
|
||||
test('strips OSC window title (BEL terminated)', () => {
|
||||
expect(stripAnsi('\x1b]0;my title\x07text')).toBe('text')
|
||||
})
|
||||
|
||||
test('strips OSC window title (ESC backslash terminated)', () => {
|
||||
expect(stripAnsi('\x1b]0;my title\x1b\\text')).toBe('text')
|
||||
})
|
||||
|
||||
test('strips OSC spanning newlines', () => {
|
||||
expect(stripAnsi('\x1b]0;line1\nline2\x07after')).toBe('after')
|
||||
})
|
||||
|
||||
test('strips only to the first OSC terminator', () => {
|
||||
expect(stripAnsi('\x1b]0;title\x07middle\x1b]0;other\x07end')).toBe(
|
||||
'middleend'
|
||||
)
|
||||
})
|
||||
|
||||
test('strips cursor movement', () => {
|
||||
expect(stripAnsi('\x1b[2Ax\x1b[1000Dy')).toBe('xy')
|
||||
})
|
||||
|
||||
test('strips erase line', () => {
|
||||
expect(stripAnsi('\x1b[2Kdone')).toBe('done')
|
||||
})
|
||||
test('strips DCS (ESC backslash terminated)', () => {
|
||||
expect(stripAnsi('\x1bPabc\x1b\\done')).toBe('done')
|
||||
})
|
||||
|
||||
test('strips DCS sixel payload', () => {
|
||||
expect(stripAnsi('\x1bPq#0;2;0;0;0~~@@\x1b\\image')).toBe('image')
|
||||
})
|
||||
|
||||
test('strips SOS', () => {
|
||||
expect(stripAnsi('\x1bXhidden\x1b\\ok')).toBe('ok')
|
||||
})
|
||||
|
||||
test('strips PM', () => {
|
||||
expect(stripAnsi('\x1b^private msg\x1b\\ok')).toBe('ok')
|
||||
})
|
||||
|
||||
test('strips APC', () => {
|
||||
expect(stripAnsi('\x1b_app command\x07ok')).toBe('ok')
|
||||
})
|
||||
|
||||
test('strips only the intro of an unterminated DCS', () => {
|
||||
expect(stripAnsi('\x1bPno terminator here')).toBe('no terminator here')
|
||||
})
|
||||
test('leaves CSI with non-ASCII digits intact', () => {
|
||||
expect(stripAnsi('\x1b[٣١mred\x1b[0m')).toBe('\x1b[٣١mred')
|
||||
})
|
||||
@@ -316,11 +316,16 @@ def strip_ansi_escape_codes(text: str) -> str:
|
||||
"""
|
||||
# Valid string terminator sequences are BEL, ESC\, and 0x9c
|
||||
st = r"(?:\u0007|\u001B\u005C|\u009C)"
|
||||
pattern = [
|
||||
rf"[\u001B\u009B][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?{st})",
|
||||
r"(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))",
|
||||
]
|
||||
ansi_escape = re.compile("|".join(pattern), re.UNICODE)
|
||||
# String controls (OSC, DCS, SOS, PM, APC): ESC ]/P/X/^/_ ... ST
|
||||
# (non-greedy until the first ST)
|
||||
strings = rf"(?:\u001B[\]PX^_][\s\S]*?{st})"
|
||||
# CSI and related: ESC/C1, optional intermediates, optional params
|
||||
# (supports ; and :) then final byte
|
||||
csi = (
|
||||
r"[\u001B\u009B][\[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]"
|
||||
)
|
||||
# re.ASCII keeps \d to 0-9 like JS; [\s\S] still matches any char
|
||||
ansi_escape = re.compile(f"{strings}|{csi}", re.ASCII)
|
||||
return ansi_escape.sub("", text)
|
||||
|
||||
|
||||
|
||||
@@ -27,3 +27,65 @@ def test_strips_colon_curly_underline():
|
||||
|
||||
def test_leaves_plain_text_unchanged():
|
||||
assert strip_ansi_escape_codes("no escape codes here") == "no escape codes here"
|
||||
|
||||
|
||||
def test_strips_osc_hyperlink():
|
||||
assert (
|
||||
strip_ansi_escape_codes("\x1b]8;;https://e2b.dev\x07E2B\x1b]8;;\x07") == "E2B"
|
||||
)
|
||||
|
||||
|
||||
def test_strips_osc_window_title_bel_terminated():
|
||||
assert strip_ansi_escape_codes("\x1b]0;my title\x07text") == "text"
|
||||
|
||||
|
||||
def test_strips_osc_esc_backslash_terminated():
|
||||
assert strip_ansi_escape_codes("\x1b]0;my title\x1b\\text") == "text"
|
||||
|
||||
|
||||
def test_strips_osc_spanning_newlines():
|
||||
assert strip_ansi_escape_codes("\x1b]0;line1\nline2\x07after") == "after"
|
||||
|
||||
|
||||
def test_strips_only_first_osc_terminator():
|
||||
assert (
|
||||
strip_ansi_escape_codes("\x1b]0;title\x07middle\x1b]0;other\x07end")
|
||||
== "middleend"
|
||||
)
|
||||
|
||||
|
||||
def test_strips_cursor_movement():
|
||||
assert strip_ansi_escape_codes("\x1b[2Ax\x1b[1000Dy") == "xy"
|
||||
|
||||
|
||||
def test_strips_erase_line():
|
||||
assert strip_ansi_escape_codes("\x1b[2Kdone") == "done"
|
||||
|
||||
|
||||
def test_strips_dcs_esc_backslash_terminated():
|
||||
assert strip_ansi_escape_codes("\x1bPabc\x1b\\done") == "done"
|
||||
|
||||
|
||||
def test_strips_dcs_sixel_payload():
|
||||
assert strip_ansi_escape_codes("\x1bPq#0;2;0;0;0~~@@\x1b\\image") == "image"
|
||||
|
||||
|
||||
def test_strips_sos():
|
||||
assert strip_ansi_escape_codes("\x1bXhidden\x1b\\ok") == "ok"
|
||||
|
||||
|
||||
def test_strips_pm():
|
||||
assert strip_ansi_escape_codes("\x1b^private msg\x1b\\ok") == "ok"
|
||||
|
||||
|
||||
def test_strips_apc():
|
||||
assert strip_ansi_escape_codes("\x1b_app command\x07ok") == "ok"
|
||||
|
||||
|
||||
def test_strips_unterminated_dcs_intro_only():
|
||||
assert strip_ansi_escape_codes("\x1bPno terminator here") == "no terminator here"
|
||||
|
||||
|
||||
def test_leaves_csi_with_non_ascii_digits_intact():
|
||||
# Python's \d would match Unicode digits without re.ASCII; JS \d never does
|
||||
assert strip_ansi_escape_codes("\x1b[٣١mred\x1b[0m") == "\x1b[٣١mred"
|
||||
|
||||
Reference in New Issue
Block a user