From dbcf82ab5d21bbec78c6f1d7791e9a0a0ba3a60b Mon Sep 17 00:00:00 2001 From: GreedyC Date: Fri, 2 Oct 2026 15:32:56 +0300 Subject: [PATCH] Fix BEL-terminated OSC 8 hyperlink widths Assisted-by: OpenAI Codex --- README.md | 3 ++- tabulate/__init__.py | 12 +++++++----- test/test_hyperlinks.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 test/test_hyperlinks.py diff --git a/README.md b/README.md index 0283a0c..ecb6ed4 100644 --- a/README.md +++ b/README.md @@ -1118,7 +1118,8 @@ Some terminals support a special grouping of ANSI escape sequences that are inte much in the same way they are shown in browsers. These are handled just as mentioned before: non-printable ANSI escape sequences are removed prior to string length calculation. The only difference with escaped hyperlinks is that column width will be based on the length of the URL _text_ rather than the URL -itself (terminals would show this text). For example: +itself (terminals would show this text). Both ST (`ESC \\`) and BEL (`\x07`) terminators +are supported for OSC 8 hyperlinks. For example: >>> len('\x1b]8;;https://example.com\x1b\\example\x1b]8;;\x1b\\') # display length is 7, showing 'example' 40 diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 12a2950..cbd882d 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -771,7 +771,7 @@ def escape_empty(val): # Also include the terminal hyperlink sequences as described here: # https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda # -# OSC 8 ; params ; uri ST display_text OSC 8 ;; ST +# OSC 8 ; params ; uri (ST or BEL) display_text OSC 8 ;; (ST or BEL) # # Example: \x1b]8;;https://example.com\x5ctext to show\x1b]8;;\x5c # @@ -780,10 +780,12 @@ def escape_empty(val): # params: 0..n optional key value pairs separated by ':' (e.g. foo=bar:baz=qux:abc=123) # URI: the actual URI with protocol scheme (e.g. https://, file://, ftp://) # ST: ESC followed by the '\' character (0x5c) +# BEL: the bell character (0x07), also accepted as an OSC terminator _esc = r"\x1b" _csi = rf"{_esc}\[" _osc = rf"{_esc}\]" _st = rf"{_esc}\\" +_osc_end = rf"(?:{_st}|\x07)" _ansi_escape_pat = rf""" ( @@ -797,10 +799,10 @@ def escape_empty(val): {_osc}8; # OSC opening (\w+=\w+:?)* # key=value params list (submatch 2) ; # delimiter - ([^{_esc}]+) # URI - anything but ESC (submatch 3) - {_st} # ST + ([^{_esc}\x07]+) # URI - anything but ESC or BEL (submatch 3) + {_osc_end} # ST or BEL ([^{_esc}]+) # link text - anything but ESC (submatch 4) - {_osc}8;;{_st} # "closing" OSC sequence + {_osc}8;;{_osc_end} # "closing" OSC sequence ) """ _ansi_codes = re.compile(_ansi_escape_pat, re.VERBOSE) @@ -1091,7 +1093,7 @@ def _strip_ansi(s): if isinstance(s, str): return _ansi_codes.sub(r"\4", s) else: # a bytestring - return _ansi_codes_bytes.sub(r"\4", s) + return _ansi_codes_bytes.sub(rb"\4", s) def _visible_width(s): diff --git a/test/test_hyperlinks.py b/test/test_hyperlinks.py new file mode 100644 index 0000000..1b6e798 --- /dev/null +++ b/test/test_hyperlinks.py @@ -0,0 +1,40 @@ +"""OSC 8 hyperlinks must not affect column widths.""" + +from pytest import mark + +import tabulate as T + + +@mark.parametrize("opening_end", ["\x1b\\", "\x07"]) +@mark.parametrize("closing_end", ["\x1b\\", "\x07"]) +@mark.parametrize("as_bytes", [False, True]) +def test_strip_hyperlink_terminators(opening_end, closing_end, as_bytes): + link = f"\x1b]8;id=docs;https://example.com/a;b{opening_end}docs\x1b]8;;{closing_end}" + text = "before " + link + " after" + expected = "before docs after" + if as_bytes: + text = text.encode() + expected = expected.encode() + assert T._strip_ansi(text) == expected + + +@mark.parametrize("opening_end", ["\x1b\\", "\x07"]) +@mark.parametrize("closing_end", ["\x1b\\", "\x07"]) +@mark.parametrize("wide_chars_mode", [False, True]) +@mark.parametrize("as_bytes", [False, True]) +def test_hyperlink_table_alignment( + monkeypatch, opening_end, closing_end, wide_chars_mode, as_bytes +): + monkeypatch.setattr(T, "WIDE_CHARS_MODE", wide_chars_mode) + link = f"\x1b]8;;https://example.com{opening_end}docs\x1b]8;;{closing_end}" + cell = link.encode() if as_bytes else link + result = T.tabulate([[cell, "ok"]], headers=[link, "state"], tablefmt="grid") + expected = T.tabulate([["docs", "ok"]], headers=["docs", "state"], tablefmt="grid") + assert result.replace(link, "docs") == expected + assert result.count(link) == 2 + + +def test_adjacent_hyperlinks_with_different_terminators(): + first = "\x1b]8;;https://example.com/one\x07one\x1b]8;;\x07" + second = "\x1b]8;;https://example.com/two\x1b\\two\x1b]8;;\x1b\\" + assert T._strip_ansi(first + " " + second) == "one two"