diff --git a/README.md b/README.md index 0283a0c..04f3e7e 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,9 @@ Mars 3390 641.85 ``` +An iterable of column labels, including a NumPy array, supplies literal +labels such as `"firstrow"` or `"keys"`. + If `headers="firstrow"`, then the first row of data is used: ```pycon @@ -151,7 +154,9 @@ called row index. To add a similar column to any other type of table, pass `showindex="always"` or `showindex=True` argument to `tabulate()`. To suppress row indices for all types of data, pass `showindex="never"` or `showindex=False`. To add a custom row index column, pass -`showindex=rowIDs`, where `rowIDs` is some iterable: +`showindex=rowIDs`, where `rowIDs` is some iterable, such as a list, +NumPy array, or pandas Index. Custom row indices replace a DataFrame's +original index and do not inherit its name: ```pycon >>> print(tabulate([["F",24],["M",19]], showindex="always")) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 12a2950..e1ee6c2 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1459,9 +1459,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): """ - try: - bool(headers) - except ValueError: # numpy.ndarray, pandas.core.index.Index, ... + if not isinstance(headers, (str, dict)): headers = list(headers) err_msg = ( @@ -1469,6 +1467,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): "like a list of lists or similar." "\nDid you forget a pair of extra [] or ',' in ()?" ) + showindex_is_a_str = type(showindex) in [str, bytes] index = None if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): # dict-like and pandas.DataFrame? @@ -1483,7 +1482,11 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): elif hasattr(tabular_data, "index"): # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) keys = list(tabular_data) - if showindex in ["default", "always", True] and tabular_data.index.name is not None: + if ( + (showindex_is_a_str or not isinstance(showindex, (Sized, Iterable))) + and showindex in ["default", "always", True] + and tabular_data.index.name is not None + ): if isinstance(tabular_data.index.name, list): keys[:0] = tabular_data.index.name else: @@ -1590,7 +1593,6 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): rows = [r if _is_separating_line(r) else list(r) for r in rows] # add or remove an index column - showindex_is_a_str = type(showindex) in [str, bytes] if showindex_is_a_str and showindex == "default" and index is not None: rows = _prepend_row_index(rows, index) elif isinstance(showindex, Sized) and not showindex_is_a_str: diff --git a/test/test_dataframe_indices.py b/test/test_dataframe_indices.py new file mode 100644 index 0000000..0279fac --- /dev/null +++ b/test/test_dataframe_indices.py @@ -0,0 +1,66 @@ +"""DataFrame row-index options must not compare array-like values as scalars.""" + +import pytest + +from tabulate import tabulate + + +@pytest.mark.parametrize("index_type", ["array", "Index", "Series"]) +@pytest.mark.parametrize("index_name", [None, "original"]) +@pytest.mark.parametrize("row_ids", [[], [10, 20], [1], ["default"], ["always"], [True]]) +def test_dataframe_array_like_row_indices(index_type, index_name, row_ids): + numpy = pytest.importorskip("numpy") + pandas = pytest.importorskip("pandas") + frame = pandas.DataFrame({"value": range(len(row_ids))}) + frame.index.name = index_name + factory = numpy.array if index_type == "array" else getattr(pandas, index_type) + indices = factory(row_ids) + + expected = tabulate(frame, headers="keys", showindex=list(indices)) + actual = tabulate(frame, headers="keys", showindex=indices) + + assert actual == expected + assert "original" not in actual + + +def test_dataframe_sequence_row_indices(): + pandas = pytest.importorskip("pandas") + frame = pandas.DataFrame({"value": [1, 2]}) + frame.index.name = "original" + + class RowIndices: + def __len__(self): + return 2 + + def __getitem__(self, index): + return [10, 20][index] + + def __eq__(self, other): + raise AssertionError("Custom row indices must not be compared to scalar modes") + + assert tabulate(frame, headers="keys", showindex=RowIndices()) == tabulate( + frame, headers="keys", showindex=[10, 20] + ) + + +@pytest.mark.parametrize("showindex", ["default", "always", True, "never", False]) +def test_dataframe_scalar_row_index_options(showindex): + pandas = pytest.importorskip("pandas") + frame = pandas.DataFrame({"value": [1, 2]}) + frame.index.name = "original" + + actual = tabulate(frame, headers="keys", showindex=showindex) + + assert ("original" in actual) == (showindex in ["default", "always", True]) + + +def test_dataframe_numpy_boolean_row_index_options(): + numpy = pytest.importorskip("numpy") + pandas = pytest.importorskip("pandas") + frame = pandas.DataFrame({"value": [1, 2]}) + frame.index.name = "original" + + for showindex in (True, False): + assert tabulate(frame, headers="keys", showindex=numpy.bool_(showindex)) == tabulate( + frame, headers="keys", showindex=showindex + ) diff --git a/test/test_header_iterables.py b/test/test_header_iterables.py new file mode 100644 index 0000000..bb82ca5 --- /dev/null +++ b/test/test_header_iterables.py @@ -0,0 +1,26 @@ +"""Iterable column labels must not be interpreted as scalar header modes.""" + +import pytest + +from tabulate import tabulate + + +@pytest.mark.parametrize("input_type", ["list", "numpy", "dict", "dataframe"]) +@pytest.mark.parametrize( + "labels", [["firstrow"], ["keys"], ["literal"], ["firstrow", "keys"], [], [0], [True]] +) +def test_numpy_array_column_labels(input_type, labels): + numpy = pytest.importorskip("numpy") + rows = [["keep first"], ["keep second"]] + if input_type == "numpy": + table = numpy.array(rows) + elif input_type == "dict": + table = {"column": [row[0] for row in rows]} + elif input_type == "dataframe": + pandas = pytest.importorskip("pandas") + table = pandas.DataFrame(rows) + else: + table = rows + headers = numpy.array(labels) + + assert tabulate(table, headers=headers) == tabulate(table, headers=list(headers))