diff --git a/AUTHORS b/AUTHORS index 2ca69cce..819eef27 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,6 +9,7 @@ Here is a list of passed and present much-appreciated contributors: Bruno Soares Claude Paroz Daniel Santos + Egor Osokin Erik Youngren Hugo van Kemenade Iuri de Silvio diff --git a/docs/formats.rst b/docs/formats.rst index c3620da6..ab904a0c 100644 --- a/docs/formats.rst +++ b/docs/formats.rst @@ -234,6 +234,19 @@ The ``import_set()`` method also supports a ``skip_lines`` parameter that you can set to a number of lines that should be skipped before starting to read data. +The ``export_set()`` method supports a ``column_width`` parameter. Depending on the +value you pass, the column width will be set accordingly. It can be either ``None``, an integer, or "adaptive". +If "adaptive" is passed, the column width will be unique for every column and will be +calculated based on values' length. For example:: + + data = tablib.Dataset() + data.export('xlsx', column_width='adaptive') + + + +.. versionchanged:: 3.3.0 + The ``column_width`` parameter for ``export_set()`` was added. + .. versionchanged:: 3.1.0 The ``skip_lines`` parameter for ``import_set()`` was added. diff --git a/src/tablib/formats/_xlsx.py b/src/tablib/formats/_xlsx.py index b2628443..bd200da9 100644 --- a/src/tablib/formats/_xlsx.py +++ b/src/tablib/formats/_xlsx.py @@ -1,5 +1,6 @@ """ Tablib - XLSX Support. """ +from __future__ import annotations import re from io import BytesIO @@ -35,12 +36,17 @@ def detect(cls, stream): return False @classmethod - def export_set(cls, dataset, freeze_panes=True, invalid_char_subst="-"): + def export_set( + cls, dataset, freeze_panes=True, invalid_char_subst="-", + column_width: str | int | None = "adaptive" + ): """Returns XLSX representation of Dataset. If dataset.title contains characters which are considered invalid for an XLSX file sheet name (http://www.excelcodex.com/2012/06/worksheets-naming-conventions/), they will be replaced with `invalid_char_subst`. + + column_width: can be None, an integer, or "adaptive" """ wb = Workbook() ws = wb.worksheets[0] @@ -51,6 +57,11 @@ def export_set(cls, dataset, freeze_panes=True, invalid_char_subst="-"): ) cls.dset_sheet(dataset, ws, freeze_panes=freeze_panes) + if isinstance(column_width, str) and column_width != "adaptive": + raise ValueError(f"Unsupported value `{column_width}` passed to `column_width` " + "parameter. It supports 'adaptive' or integer values") + + cls._adapt_column_width(ws, column_width) stream = BytesIO() wb.save(stream) @@ -166,3 +177,25 @@ def dset_sheet(cls, dataset, ws, freeze_panes=True): cell.value = col except (ValueError, TypeError): cell.value = str(col) + + @classmethod + def _adapt_column_width(cls, worksheet, + width: str | int | None) -> None: + if width is None: + return + + column_widths = [] + if isinstance(width, str) and width == "adaptive": + for row in worksheet.values: + for i, cell in enumerate(row): + cell = str(cell) + if len(column_widths) > i: + if len(cell) > column_widths[i]: + column_widths[i] = len(cell) + else: + column_widths += [len(cell)] + else: + column_widths = [width] * worksheet.max_column + + for i, column_width in enumerate(column_widths, 1): # start at 1 + worksheet.column_dimensions[get_column_letter(i)].width = column_width diff --git a/tests/test_tablib.py b/tests/test_tablib.py index 3821cc05..d32cb33c 100755 --- a/tests/test_tablib.py +++ b/tests/test_tablib.py @@ -9,9 +9,11 @@ from collections import OrderedDict from io import BytesIO, StringIO from pathlib import Path +from tempfile import TemporaryFile from uuid import uuid4 from MarkupPy import markup +from openpyxl import load_workbook import tablib from tablib.core import Row, detect_format @@ -1100,6 +1102,47 @@ def test_xlsx_bad_dimensions(self): data = tablib.Dataset().load(fh, read_only=False) self.assertEqual(data.height, 3) + def _helper_export_column_width(self, input_arg): + """check that column width adapts to value length""" + def _get_width(data, input_arg): + xlsx_content = data.export('xlsx', column_width=input_arg) + wb = load_workbook(filename=BytesIO(xlsx_content)) + ws = wb.active + return ws.column_dimensions['A'].width + + xls_source = Path(__file__).parent / 'files' / 'xlsx_cell_values.xlsx' + with xls_source.open('rb') as fh: + data = tablib.Dataset().load(fh) + width_before = _get_width(data, input_arg) + data.append([ + 'verylongvalue-verylongvalue-verylongvalue-verylongvalue-verylongvalue-verylongvalue-verylongvalue-verylongvalue', + ]) + width_after = _get_width(data, width_before) + return width_before, width_after + + def test_xlsx_column_width_none(self): + """check column width with None""" + width_before, width_after = self._helper_export_column_width(None) + self.assertEqual(width_before, 13) + self.assertEqual(width_after, 13) + + def test_xlsx_column_width_adaptive(self): + """check column width with 'adaptive'""" + width_before, width_after = self._helper_export_column_width("adaptive") + self.assertEqual(width_before, 11) + self.assertEqual(width_after, 11) + + def test_xlsx_column_width_integer(self): + """check column width with an integer""" + width_before, width_after = self._helper_export_column_width(10) + self.assertEqual(width_before, 10) + self.assertEqual(width_after, 10) + + def test_xlsx_column_width_value_error(self): + """check column width with invalid input""" + with self.assertRaises(ValueError): + self._helper_export_column_width("invalid input") + class JSONTests(BaseTestCase): def test_json_format_detect(self):