Repository: jazzband/tablib (python)

Issue:
appending rows when there are dynamic columns 
At the moment we cannot add rows (using append, rpush, lpush) when there is already a dynamic column: a function object can be added at the appropriate place in the row, but unlike a normal dynamic column it will not be executed.



Pull request description:
Fixes #572 - Allow appending rows after a dynamic column was inserted

Patch:
diff --git a/HISTORY.md b/HISTORY.md
index 1755100..71350af 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -7,6 +7,9 @@
 - The html format now supports importing from HTML content (#243)
 - The ODS format now supports importing from .ods files (#567). The support is
   still a bit experimental.
+- When adding rows to a dataset with dynamic columns, it's now possible to
+  provide only static values, and dynamic column values will be automatically
+  calculated and added to the row (#572).
 
 ### Changes
 
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index e5cd12b..d21e7fc 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -278,6 +278,15 @@ Adding this function to our dataset as a dynamic column would result in: ::
     - {Age: 22, First Name: Kenneth, Gender: Male, Last Name: Reitz}
     - {Age: 20, First Name: Bessie, Gender: Female, Last Name: Monke}
 
+When you add new rows to a dataset that contains dynamic columns, you should
+either provide all values in the row, or only the non-dynamic values and then
+the dynamic values will be automatically generated using the function initially
+provided for the column calculation.
+
+..versionchanged:: 3.6.0
+
+    In older versions, you could only add new rows with fully-populated rows,
+    including dynamic columns.
 
 .. _tags:
 
diff --git a/src/tablib/core.py b/src/tablib/core.py
index a7ebff2..ef3f6fe 100644
--- a/src/tablib/core.py
+++ b/src/tablib/core.py
@@ -155,6 +155,9 @@ class Dataset:
         # (column, callback) tuples
         self._formatters = []
 
+        # {col_index: col_func}
+        self._dynamic_columns = {}
+
         self.headers = kwargs.get('headers')
 
         self.title = kwargs.get('title')
@@ -187,6 +190,8 @@ class Dataset:
 
                 pos = self.headers.index(key)
                 del self.headers[pos]
+                if pos in self._dynamic_columns:
+                    del self._dynamic_columns[pos]
 
                 for i, row in enumerate(self._data):
 
@@ -238,7 +243,13 @@ class Dataset:
     def _validate(self, row=None, col=None, safety=False):
         """Assures size of every row in dataset is of proper proportions."""
         if row:
-            is_valid = (len(row) == self.width) if self.width else True
+            if self.width:
+                is_valid = (
+                    len(row) == self.width or
+                    len(row) == (self.width - len(self._dynamic_columns))
+                )
+            else:
+                is_valid = True
         elif col:
             if len(col) < 1:
                 is_valid = True
@@ -446,9 +457,13 @@ class Dataset:
 
         The default behaviour is to insert the given row to the :class:`Dataset`
         object at the given index.
-       """
+        """
 
         self._validate(row)
+        if len(row) < self.width:
+            for pos, func in self._dynamic_columns.items():
+                row = list(row)
+                row.insert(pos, func(row))
         self._data.insert(index, Row(row, tags=tags))
 
     def rpush(self, row, tags=()):
@@ -546,7 +561,8 @@ class Dataset:
             col = []
 
         # Callable Columns...
-        if hasattr(col, '__call__'):
+        if callable(col):
+            self._dynamic_columns[self.width] = col
             col = list(map(col, self._data))
 
         col = self._clean_col(col)


Test patch:
diff --git a/tests/test_tablib.py b/tests/test_tablib.py
index 6c61c20..9035c1e 100755
--- a/tests/test_tablib.py
+++ b/tests/test_tablib.py
@@ -13,7 +13,6 @@ from io import BytesIO, StringIO
 from pathlib import Path
 from uuid import uuid4
 
-import pytest
 from openpyxl.reader.excel import load_workbook
 
 import tablib
@@ -56,7 +55,7 @@ class TablibTestCase(BaseTestCase):
             'latex', 'df', 'rst',
         ]
         for format_ in all_formats:
-            if format_ in exclude:
+            if format_ in exclude or (format_ == 'df' and pandas is None):
                 continue
             dataset.export(format_)
 
@@ -182,10 +181,42 @@ class TablibTestCase(BaseTestCase):
     def test_add_callable_column(self):
         """Verify adding column with values specified as callable."""
 
-        def new_col(x):
-            return x[0]
+        def new_col(row):
+            return row[0]
+
+        def initials(row):
+            return f"{row[0][0]}{row[1][0]}"
 
         self.founders.append_col(new_col, header='first_again')
+        self.founders.append_col(initials, header='initials')
+
+        # A new row can still be appended, and the dynamic column value generated.
+        self.founders.append(('Some', 'One', 71))
+        # Also acceptable when all dynamic column values are provided.
+        self.founders.append(('Other', 'Second', 84, 'Other', 'OS'))
+
+        self.assertEqual(self.founders[3], ('Some', 'One', 71, 'Some', 'SO'))
+        self.assertEqual(self.founders[4], ('Other', 'Second', 84, 'Other', 'OS'))
+        self.assertEqual(
+            self.founders['first_again'],
+            ['John', 'George', 'Thomas', 'Some', 'Other']
+        )
+        self.assertEqual(
+            self.founders['initials'],
+            ['JA', 'GW', 'TJ', 'SO', 'OS']
+        )
+
+        # However only partial dynamic values provided is not accepted.
+        with self.assertRaises(tablib.InvalidDimensions):
+            self.founders.append(('Should', 'Crash', 60, 'Partial'))
+
+        # Add a new row after dynamic column deletion
+        del self.founders['first_again']
+        self.founders.append(('After', 'Deletion', 75))
+        self.assertEqual(
+            self.founders['initials'],
+            ['JA', 'GW', 'TJ', 'SO', 'OS', 'AD']
+        )
 
     def test_header_slicing(self):
         """Verify slicing by headers."""
@@ -290,7 +321,6 @@ class TablibTestCase(BaseTestCase):
             'c|3'
         ])
 
-    @pytest.mark.skipif(pandas is None, reason="pandas is not installed")
     def test_unicode_append(self):
         """Passes in a single unicode character and exports."""
 
@@ -299,7 +329,6 @@ class TablibTestCase(BaseTestCase):
         data.append(new_row)
         self._test_export_data_in_all_formats(data)
 
-    @pytest.mark.skipif(pandas is None, reason="pandas is not installed")
     def test_datetime_append(self):
         """Passes in a single datetime and a single date and exports."""
 
@@ -311,7 +340,6 @@ class TablibTestCase(BaseTestCase):
         data.append(new_row)
         self._test_export_data_in_all_formats(data)
 
-    @pytest.mark.skipif(pandas is None, reason="pandas is not installed")
     def test_separator_append(self):
         for _ in range(3):
             data.append_separator('foobar')
@@ -357,7 +385,6 @@ class TablibTestCase(BaseTestCase):
         dset = tablib.Dataset().load(tmp_file, 'yaml')
         self.assertEqual(dset.json, '[]')
 
-    @pytest.mark.skipif(pandas is None, reason="pandas is not installed")
     def test_auto_format_detect(self):
         """Test auto format detection."""
         # html, jira, latex, rst are export only.
@@ -371,8 +398,9 @@ class TablibTestCase(BaseTestCase):
         _ods = self.founders.export('ods')
         self.assertEqual(tablib.detect_format(_ods), 'ods')
 
-        _df = self.founders.export('df')
-        self.assertEqual(tablib.detect_format(_df), 'df')
+        if pandas is not None:
+            _df = self.founders.export('df')
+            self.assertEqual(tablib.detect_format(_df), 'df')
 
         _yaml = '- {age: 90, first_name: John, last_name: Adams}'
         self.assertEqual(tablib.detect_format(_yaml), 'yaml')