Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions tabulate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -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"""
(
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
40 changes: 40 additions & 0 deletions test/test_hyperlinks.py
Original file line number Diff line number Diff line change
@@ -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"