diff --git a/.circleci/config.yml b/.circleci/config.yml index 7de92cb..8980735 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ jobs: docker: # specify the version you desire here # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` - - image: circleci/python:3.6.1 + - image: circleci/python:3.8 # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images @@ -50,7 +50,7 @@ jobs: name: run tests command: | . venv/bin/activate - tox -e py36-extra + tox -e py38-extra - store_artifacts: path: test-reports diff --git a/CHANGELOG b/CHANGELOG index 9a6b32e..fcef4bb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -- 0.8.7: Future release. +- 0.8.7: Bug fixes. New format: `pretty`. HTML escaping. - 0.8.6: Bug fixes. Stop supporting Python 3.3, 3.4. - 0.8.5: Fix broken Windows package. Minor documentation updates. - 0.8.4: Bug fixes. diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 0167ba7..6730e8a 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,7 +1,7 @@ # update contributors and CHANGELOG in README python3 benchmark.py # then update README tox -e py33,py34,py36-extra -python3 setup.py sdist +python3 setup.py sdist bdist_wheel twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* # tag version release diff --git a/LICENSE b/LICENSE index 97eab5d..81241ec 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2011-2017 Sergey Astanin +Copyright (c) 2011-2020 Sergey Astanin and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index 058d2eb..1823818 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ Supported table formats are: - "orgtbl" - "jira" - "presto" +- "pretty" - "psql" - "rst" - "mediawiki" @@ -221,6 +222,18 @@ corresponds to the `pipe` format without alignment colons: eggs | 451 bacon | 0 +`pretty` attempts to be close to the format emitted by the PrettyTables +library: + + >>> print(tabulate(table, headers, tablefmt="pretty")) + +-------+-----+ + | item | qty | + +-------+-----+ + | spam | 42 | + | eggs | 451 | + | bacon | 0 | + +-------+-----+ + `psql` is like tables formatted by Postgres' psql cli: >>> print(tabulate(table, headers, tablefmt="psql")) @@ -319,7 +332,9 @@ MediaWiki-based sites: |<. eggs |>. 451 | |<. bacon |>. 0 | -`html` produces standard HTML markup: +`html` produces standard HTML markup as an html.escape'd str +with a ._repr_html_ method so that Jupyter Lab and Notebook display the HTML +and a .str property so that the raw HTML remains accessible: >>> print(tabulate(table, headers, tablefmt="html")) @@ -401,6 +416,16 @@ file: eggs 451 ---- ---- + +To disable this feature use `disable_numparse=True`. + + >>> print(tabulate.tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True)) + ---- ---- + Ver1 18.0 + Ver2 19.2 + ---- ---- + + ### Custom column alignment `tabulate` allows a custom column alignment to override the above. The @@ -568,6 +593,18 @@ a multiline cell, and headers with a multiline cell: more | 42 spam | +`pretty` tables: + + >>> print(tabulate(table, headers, tablefmt="pretty")) + +------+-----+ + | item | qty | + | name | | + +------+-----+ + | eggs | 451 | + | more | 42 | + | spam | | + +------+-----+ + `psql` tables: >>> print(tabulate(table, headers, tablefmt="psql")) @@ -635,24 +672,24 @@ In the same time `tabulate` is comparable to other table pretty-printers. Given a 10x10 table (a list of lists) of mixed text and numeric data, `tabulate` appears to be slower than `asciitable`, and faster than `PrettyTable` and `texttable` The following mini-benchmark -was run in Python 3.6.8 on Ubuntu 18.04 in WSL: +was run in Python 3.8.1 in Windows 10 x64: - ========================== ========== =========== + =========================== ========== =========== Table formatter time, μs rel. time =========================== ========== =========== - csv to StringIO 21.4 1.0 - join with tabs and newlines 29.6 1.4 - asciitable (0.8.0) 506.8 23.7 - tabulate (0.8.6) 1079.9 50.5 - PrettyTable (0.7.2) 2032.0 95.0 - texttable (1.6.2) 3025.7 141.4 + csv to StringIO 12.4 1.0 + join with tabs and newlines 15.7 1.3 + asciitable (0.8.0) 208.3 16.7 + tabulate (0.8.7) 492.1 39.5 + PrettyTable (0.7.2) 945.5 76.0 + texttable (1.6.2) 1239.5 99.6 =========================== ========== =========== Version history --------------- -The full version history can be found at the [changelog](./CHANGELOG). +The full version history can be found at the [changelog](https://github.com/astanin/python-tabulate/blob/master/CHANGELOG). How to contribute ----------------- @@ -704,4 +741,6 @@ naught101, John Vandenberg, Zack Dever, Christian Clauss, Benjamin Maier, Andy MacKinlay, Thomas Roten, Jue Wang, Joe King, Samuel Phan, Nick Satterly, Daniel Robbins, Dmitry B, Lars Butler, Andreas Maier, Dick Marinus, Sébastien Celles, Yago González, Andrew Gaul, Wim Glenn, -Jean Michel Rouly, Tim Gates, John Vandenberg, Sorin Sbarnea. +Jean Michel Rouly, Tim Gates, John Vandenberg, Sorin Sbarnea, +Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis. + diff --git a/appveyor.yml b/appveyor.yml index 324b09a..ac38dcc 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -11,10 +11,12 @@ environment: - PYTHON: "C:\\Python35" - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" + - PYTHON: "C:\\Python38" - PYTHON: "C:\\Python27-x64" - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37-x64" + - PYTHON: "C:\\Python38-x64" install: # We need wheel installed to build wheels diff --git a/setup.py b/setup.py index c2ad593..4a4e8a5 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ setup( name="tabulate", - version="0.8.6", + version="0.8.7", description="Pretty-print tabular data", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", @@ -56,6 +56,7 @@ "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", "Topic :: Software Development :: Libraries", ], py_modules=["tabulate"], diff --git a/tabulate.py b/tabulate.py index 1595238..454d464 100755 --- a/tabulate.py +++ b/tabulate.py @@ -55,9 +55,14 @@ def _is_file(f): except ImportError: wcwidth = None +try: + from html import escape as htmlescape +except ImportError: + from cgi import escape as htmlescape + __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -__version__ = "0.8.6" +__version__ = "0.8.7" # minimum extra space in headers @@ -143,6 +148,8 @@ def _pipe_segment_with_colons(align, colwidth): def _pipe_line_with_colons(colwidths, colaligns): """Return a horizontal line with optional colons to indicate column's alignment (as in `pipe` output format).""" + if not colaligns: # e.g. printing an empty data frame (github issue #15) + colaligns = [""] * len(colwidths) segments = [_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)] return "|" + "|".join(segments) + "|" @@ -172,7 +179,7 @@ def _textile_row_with_attrs(cell_values, colwidths, colaligns): def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): # this table header will be suppressed if there is a header row - return "\n".join(["
", ""]) + return "
\n" def _html_row_with_attrs(celltag, cell_values, colwidths, colaligns): @@ -183,12 +190,12 @@ def _html_row_with_attrs(celltag, cell_values, colwidths, colaligns): "decimal": ' style="text-align: right;"', } values_with_attrs = [ - "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), c) + "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), htmlescape(c)) for c, a in zip(cell_values, colaligns) ] - rowhtml = "" + "".join(values_with_attrs).rstrip() + "" + rowhtml = "{}".format("".join(values_with_attrs).rstrip()) if celltag == "th": # it's a header row, create a new table header - rowhtml = "\n".join(["
", "", rowhtml, "", ""]) + rowhtml = "
\n\n{}\n\n".format(rowhtml) return rowhtml @@ -352,6 +359,16 @@ def escape_empty(val): padding=1, with_header_hide=None, ), + "pretty": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("+", "-", "+", "+"), + linebetweenrows=None, + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), "psql": TableFormat( lineabove=Line("+", "-", "+", "+"), linebelowheader=Line("|", "-", "+", "|"), @@ -484,6 +501,7 @@ def escape_empty(val): "orgtbl": "orgtbl", "jira": "jira", "presto": "presto", + "pretty": "pretty", "psql": "psql", "rst": "rst", } @@ -1340,7 +1358,9 @@ def tabulate( | eggs || align="right"| 451 |} - "html" produces HTML markup: + "html" produces HTML markup as an html.escape'd str + with a ._repr_html_ method so that Jupyter Lab and Notebook display the HTML + and a .str property so that the raw HTML remains accessible: >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], ... headers="firstrow", tablefmt="html")) @@ -1412,6 +1432,17 @@ def tabulate( if tablefmt == "rst": list_of_lists, headers = _rst_escape_first_column(list_of_lists, headers) + # PrettyTable formatting does not use any extra padding. + # Numbers are not parsed and are treated the same as strings for alignment. + # Check if pretty is the format being used and override the defaults so it + # does not impact other formats. + min_padding = MIN_PADDING + if tablefmt == "pretty": + min_padding = 0 + disable_numparse = True + numalign = "center" + stralign = "center" + # optimization: look for ANSI control codes once, # enable smart width functions only if a control code is found plain_text = "\t".join( @@ -1421,7 +1452,11 @@ def tabulate( has_invisible = re.search(_invisible_codes, plain_text) enable_widechars = wcwidth is not None and WIDE_CHARS_MODE - if tablefmt in multiline_formats and _is_multiline(plain_text): + if ( + not isinstance(tablefmt, TableFormat) + and tablefmt in multiline_formats + and _is_multiline(plain_text) + ): tablefmt = multiline_formats.get(tablefmt, tablefmt) is_multiline = True else: @@ -1458,7 +1493,7 @@ def tabulate( for idx, align in enumerate(colalign): aligns[idx] = align minwidths = ( - [width_fn(h) + MIN_PADDING for h in headers] if headers else [0] * len(cols) + [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) ) cols = [ _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) @@ -1569,6 +1604,19 @@ def _append_line(lines, colwidths, colaligns, linefmt): return lines +class JupyterHTMLStr(str): + """Wrap the string with a _repr_html_ method so that Jupyter + displays the HTML table""" + + def _repr_html_(self): + return self + + @property + def str(self): + """add a .str property so that the raw string is still accessible""" + return self + + def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): """Produce a plain-text representation of the table.""" lines = [] @@ -1610,7 +1658,11 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): _append_line(lines, padded_widths, colaligns, fmt.linebelow) if headers or rows: - return "\n".join(lines) + output = "\n".join(lines) + if fmt.lineabove == _html_begin_table_without_header: + return JupyterHTMLStr(output) + else: + return output else: # a completely empty table return "" diff --git a/test/test_output.py b/test/test_output.py index 1eb67c1..0f2d37e 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -700,6 +700,112 @@ def test_psql_multiline_with_empty_cells_headerless(): assert_equal(expected, result) +def test_pretty(): + "Output: pretty with headers" + expected = "\n".join( + [ + "+---------+---------+", + "| strings | numbers |", + "+---------+---------+", + "| spam | 41.9999 |", + "| eggs | 451.0 |", + "+---------+---------+", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="pretty") + assert_equal(expected, result) + + +def test_pretty_headerless(): + "Output: pretty without headers" + expected = "\n".join( + [ + "+------+---------+", + "| spam | 41.9999 |", + "| eggs | 451.0 |", + "+------+---------+", + ] + ) + result = tabulate(_test_table, tablefmt="pretty") + assert_equal(expected, result) + + +def test_pretty_multiline_headerless(): + "Output: pretty with multiline cells without headers" + table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] + expected = "\n".join( + [ + "+---------+-----------+", + "| foo bar | hello |", + "| baz | |", + "| bau | |", + "| | multiline |", + "| | world |", + "+---------+-----------+", + ] + ) + result = tabulate(table, tablefmt="pretty") + assert_equal(expected, result) + + +def test_pretty_multiline(): + "Output: pretty with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs") + expected = "\n".join( + [ + "+-----------+-----------+", + "| more | more spam |", + "| spam \x1b[31meggs\x1b[0m | & eggs |", + "+-----------+-----------+", + "| 2 | foo |", + "| | bar |", + "+-----------+-----------+", + ] + ) + result = tabulate(table, headers, tablefmt="pretty") + assert_equal(expected, result) + + +def test_pretty_multiline_with_empty_cells(): + "Output: pretty with multiline cells and empty cells with headers" + table = [ + ["hdr", "data", "fold"], + ["1", "", ""], + ["2", "very long data", "fold\nthis"], + ] + expected = "\n".join( + [ + "+-----+----------------+------+", + "| hdr | data | fold |", + "+-----+----------------+------+", + "| 1 | | |", + "| 2 | very long data | fold |", + "| | | this |", + "+-----+----------------+------+", + ] + ) + result = tabulate(table, headers="firstrow", tablefmt="pretty") + assert_equal(expected, result) + + +def test_pretty_multiline_with_empty_cells_headerless(): + "Output: pretty with multiline cells and empty cells without headers" + table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]] + expected = "\n".join( + [ + "+---+----------------+------+", + "| 0 | | |", + "| 1 | | |", + "| 2 | very long data | fold |", + "| | | this |", + "+---+----------------+------+", + ] + ) + result = tabulate(table, tablefmt="pretty") + assert_equal(expected, result) + + def test_jira(): "Output: jira with headers" expected = "\n".join( @@ -897,23 +1003,30 @@ def test_moinmoin_headerless(): assert_equal(expected, result) +_test_table_html_headers = ["", "<&numbers&>"] +_test_table_html = [["spam >", 41.9999], ["eggs &", 451.0]] +assert_equal.__self__.maxDiff = None + + def test_html(): "Output: html with headers" expected = "\n".join( [ "
", "", - '', + '', # noqa "", "", - '', - '', + '', + '', "", "
strings numbers
<strings> <&numbers&>
spam 41.9999
eggs 451
spam > 41.9999
eggs & 451
", ] ) - result = tabulate(_test_table, _test_table_headers, tablefmt="html") + result = tabulate(_test_table_html, _test_table_html_headers, tablefmt="html") assert_equal(expected, result) + assert hasattr(result, "_repr_html_") + assert result._repr_html_() == result.str def test_html_headerless(): @@ -922,14 +1035,16 @@ def test_html_headerless(): [ "", "", - '', - '', + '', + '', "", "
spam 41.9999
eggs451
spam > 41.9999
eggs &451
", ] ) - result = tabulate(_test_table, tablefmt="html") + result = tabulate(_test_table_html, tablefmt="html") assert_equal(expected, result) + assert hasattr(result, "_repr_html_") + assert result._repr_html_() == result.str def test_latex(): diff --git a/test/test_regression.py b/test/test_regression.py index f3da215..e79aad8 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -4,7 +4,7 @@ from __future__ import print_function from __future__ import unicode_literals -from tabulate import tabulate, _text_type, _long_type +from tabulate import tabulate, _text_type, _long_type, TableFormat, Line, DataRow from common import assert_equal, assert_in, SkipTest @@ -356,3 +356,30 @@ def test_ragged_rows(): expected = "\n".join(["- - - -", "1 2 3", "1 2", "1 2 3 4", "- - - -"]) result = tabulate(table) assert_equal(result, expected) + + +def test_empty_pipe_table_with_columns(): + "Regression: allow empty pipe tables with columns, like empty dataframes (github issue #15)" + table = [] + headers = ["Col1", "Col2"] + expected = "\n".join(["| Col1 | Col2 |", "|--------|--------|"]) + result = tabulate(table, headers, tablefmt="pipe") + assert_equal(result, expected) + + +def test_custom_tablefmt(): + "Regression: allow custom TableFormat that specifies with_header_hide (github issue #20)" + tablefmt = TableFormat( + lineabove=Line("", "-", " ", ""), + linebelowheader=Line("", "-", " ", ""), + linebetweenrows=None, + linebelow=Line("", "-", " ", ""), + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, + with_header_hide=["lineabove", "linebelow"], + ) + rows = [["foo", "bar"], ["baz", "qux"]] + expected = "\n".join(["A B", "--- ---", "foo bar", "baz qux"]) + result = tabulate(rows, headers=["A", "B"], tablefmt=tablefmt) + assert_equal(result, expected) diff --git a/tox.ini b/tox.ini index c948bc5..26afadc 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py27, py35, py36, py37 +envlist = lint, py27, py35, py36, py37, py38 [testenv] commands = nosetests -v --with-doctest test/ tabulate.py @@ -78,6 +78,21 @@ deps = pandas wcwidth +[testenv:py38] +basepython = python3.8 +commands = nosetests -v --with-doctest -i 'py3test_*' test/ tabulate.py +deps = + nose + +[testenv:py38-extra] +basepython = python3.8 +commands = nosetests -v --with-doctest -i 'py3test_*' test/ tabulate.py +deps = + nose + numpy + pandas + wcwidth + [flake8] max-complexity = 22 max-line-length = 99