Repository: jazzband/tablib (python)

Issue:
Inserting column with insert_col fails if dataset contains 0 rows
I am trying to add additional column to a Dataset that is created from a dictionary. I have headers defined separately. However, when dictionary is empty (no rows to export because how data is filtered) `insert_col` method fails with error:
```
File "/usr/local/lib/python3.10/site-packages/tablib/core.py", line 372, in _clean_col
    header = [col.pop(0)]
IndexError: pop from empty list
```
After looking in to the code of the method `_clean_col`, my guess, that `if` statement:  https://github.com/jazzband/tablib/blob/4fd9a68c24a2a95fc471d0fc29a2a911b469f93f/src/tablib/core.py#L373-L376

should be opposite:
```python
        if not self.headers:
            header = [col.pop(0)]
        else:
            header = []
```
or:
```python
        if self.headers:
            header = []
        else:
            header = [col.pop(0)]
```

Pull request description:
Fixes #583 - Allow inserting columns for dataset with headers and no values

Patch:
diff --git a/src/tablib/core.py b/src/tablib/core.py
index 956458b..ad5ec8d 100644
--- a/src/tablib/core.py
+++ b/src/tablib/core.py
@@ -73,6 +73,9 @@ class Row:
     def insert(self, index, value):
         self._row.insert(index, value)
 
+    def copy(self):
+        return Row(self._row.copy(), self.tags.copy())
+
     def __contains__(self, item):
         return item in self._row
 
@@ -270,27 +273,25 @@ class Dataset:
 
         _data = list(self._data)
 
-        # Execute formatters
-        if self._formatters:
-            for row_i, row in enumerate(_data):
+        def format_row(row):
+            # Execute formatters
+            if self._formatters:
+                row = row.copy()  # To not mutate internal data structure
                 for col, callback in self._formatters:
-                    try:
-                        if col is None:
-                            for j, c in enumerate(row):
-                                _data[row_i][j] = callback(c)
-                        else:
-                            _data[row_i][col] = callback(row[col])
-                    except IndexError:
-                        raise InvalidDatasetIndex
+                    if col is None:
+                        # Apply formatter to all cells
+                        row = [callback(cell) for cell in row]
+                    else:
+                        row[col] = callback(row[col])
+            return list(row)
 
         if self.headers:
             if dicts:
-                data = [dict(list(zip(self.headers, data_row))) for data_row in _data]
+                data = [dict(list(zip(self.headers, format_row(row)))) for row in _data]
             else:
-                data = [list(self.headers)] + list(_data)
+                data = [list(self.headers)] + [format_row(row) for row in _data]
         else:
-            data = [list(row) for row in _data]
-
+            data = [format_row(row) for row in _data]
         return data
 
     def _get_headers(self):
@@ -366,19 +367,14 @@ class Dataset:
     dict = property(_get_dict, _set_dict)
 
     def _clean_col(self, col):
-        """Prepares the given column for insert/append."""
+        """Prepares the given column for insert/append. `col` is not supposed to
+           contain any header value.
+        """
 
         col = list(col)
 
-        if self.headers:
-            header = [col.pop(0)]
-        else:
-            header = []
-
         if len(col) == 1 and hasattr(col[0], '__call__'):
-
             col = list(map(col[0], self._data))
-        col = tuple(header + col)
 
         return col
 
@@ -622,7 +618,8 @@ class Dataset:
     def add_formatter(self, col, handler):
         """Adds a formatter to the :class:`Dataset`.
 
-        :param col: column to. Accepts index int or header str.
+        :param col: column to. Accepts index int, header str, or None to apply
+                    the formatter to all columns.
         :param handler: reference to callback function to execute against
                         each cell value.
         """
@@ -633,7 +630,7 @@ class Dataset:
             else:
                 raise KeyError
 
-        if not col > self.width:
+        if col is None or col <= self.width:
             self._formatters.append((col, handler))
         else:
             raise InvalidDatasetIndex


Test patch:
diff --git a/tests/test_tablib.py b/tests/test_tablib.py
index ee34186..fbb26f9 100755
--- a/tests/test_tablib.py
+++ b/tests/test_tablib.py
@@ -117,10 +117,19 @@ class TablibTestCase(BaseTestCase):
 
         # With Headers
         data.headers = ('fname', 'lname')
-        new_col = [21, 22]
-        data.append_col(new_col, header='age')
+        age_col = [21, 22]
+        data.append_col(age_col, header='age')
+        size_col = [1.65, 1.86]
+        data.insert_col(1, size_col, header='size')
 
-        self.assertEqual(data['age'], new_col)
+        self.assertEqual(data['age'], age_col)
+        self.assertEqual(data['size'], size_col)
+
+    def test_add_column_no_data_with_headers(self):
+        """Verify adding empty column when dataset has only headers."""
+        data.headers = ('fname', 'lname')
+        data.insert_col(1, [], header='size')
+        self.assertEqual(data.headers, ['fname', 'size', 'lname'])
 
     def test_add_column_no_data_no_headers(self):
         """Verify adding new column with no headers."""
@@ -564,12 +573,34 @@ class TablibTestCase(BaseTestCase):
         """Confirm formatters are being triggered."""
 
         def _formatter(cell_value):
-            return str(cell_value).upper()
+            return str(cell_value)[1:]
 
         self.founders.add_formatter('last_name', _formatter)
 
-        for name in [r['last_name'] for r in self.founders.dict]:
-            self.assertTrue(name.isupper())
+        expected = [
+            {'first_name': 'John', 'last_name': 'dams', 'gpa': 90},
+            {'first_name': 'George', 'last_name': 'ashington', 'gpa': 67},
+            {'first_name': 'Thomas', 'last_name': 'efferson', 'gpa': 50},
+        ]
+        self.assertEqual(self.founders.dict, expected)
+        # Test once more as the result should be the same
+        self.assertEqual(self.founders.dict, expected)
+
+    def test_formatters_all_cols(self):
+        """
+        Passing None as first add_formatter param apply formatter to all columns.
+        """
+
+        def _formatter(cell_value):
+            return str(cell_value).upper()
+
+        self.founders.add_formatter(None, _formatter)
+
+        self.assertEqual(self.founders.dict, [
+            {'first_name': 'JOHN', 'last_name': 'ADAMS', 'gpa': '90'},
+            {'first_name': 'GEORGE', 'last_name': 'WASHINGTON', 'gpa': '67'},
+            {'first_name': 'THOMAS', 'last_name': 'JEFFERSON', 'gpa': '50'},
+        ])
 
     def test_unicode_renders_markdown_table(self):
         # add another entry to test right field width for