Repository: django-import-export/django-import-export

Issue:
Improve error information, include exact value and column
It would be nice quality of life feature if we could return more detailed error info. Including exact value/column which raised it.
Something similar to https://github.com/django-import-export/django-import-export/issues/1215

End goal would be for [row_errors](https://github.com/django-import-export/django-import-export/blob/main/import_export/results.py#L131) to be changed to namedtuple or similar formatted the same as currently but with expanded info about exact value/column where error happened. 
Caller can than more easily format this and return back in same file by appending it or new file or show in template...

As far i can see this return line [import_obj](https://github.com/django-import-export/django-import-export/blob/main/import_export/resources.py#L543) would need modification.

I would open pr for this. Any hints before i start?


Discussion:
Thanks for looking into this, it would be cool to have better error reporting, perhaps involving some kind of base class which people could override for their own formats. 

Please be aware of making any changes which could affect the existing API.  Any breaking changes would have to go into v3 which we are hoping will be released in the next 6 months or so.

If you wanted to write a short proposal and add it to this issue, I would be happy to take a look before you dive in and implement.  You might want to look at the [contribution guidelines](https://github.com/django-import-export/django-import-export/blob/main/CONTRIBUTING.md) if you haven't already.

I would also say have a look through existing PRs and issues because IIRC others may have attempted similar in the past.

 If you wanted an easy start, you could pick up #1215 as I think that would be quite an easy fix.

Resolving patch:
diff --git a/import_export/exceptions.py b/import_export/exceptions.py
--- a/import_export/exceptions.py
+++ b/import_export/exceptions.py
@@ -8,3 +8,19 @@ class FieldError(ImportExportError):
     """Raised when a field encounters an error."""
 
     pass
+
+
+class ImportError(ImportExportError):
+    def __init__(self, error, number=None, row=None):
+        """A wrapper for errors thrown from the import process.
+
+        :param error: The underlying error that occurred.
+        :param number: The row number of the row containing the error (if obtainable).
+        :param row: The row containing the error (if obtainable).
+        """
+        self.error = error
+        self.number = number
+        self.row = row
+
+    def __str__(self):
+        return f"{self.number}: {self.error} ({self.row})"
diff --git a/import_export/resources.py b/import_export/resources.py
--- a/import_export/resources.py
+++ b/import_export/resources.py
@@ -21,9 +21,8 @@
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
-from . import widgets
+from . import exceptions, widgets
 from .declarative import DeclarativeMetaclass, ModelDeclarativeMetaclass
-from .exceptions import FieldError
 from .fields import Field
 from .results import Error, Result, RowResult
 from .utils import atomic_if_using_transaction, get_related_model
@@ -659,9 +658,11 @@ def handle_import_error(self, result, error, raise_errors=False):
         logger.debug(error, exc_info=error)
         if result:
             tb_info = traceback.format_exc()
-            result.append_base_error(self.get_error_result_class()(error, tb_info))
+            result.append_base_error(
+                self.get_error_result_class()(error, traceback=tb_info)
+            )
         if raise_errors:
-            raise
+            raise exceptions.ImportError(error)
 
     def import_row(self, row, instance_loader, **kwargs):
         r"""
@@ -762,7 +763,11 @@ def import_row(self, row, instance_loader, **kwargs):
             if not isinstance(e, TransactionManagementError):
                 logger.debug(e, exc_info=e)
             tb_info = traceback.format_exc()
-            row_result.errors.append(self.get_error_result_class()(e, tb_info, row))
+            row_result.errors.append(
+                self.get_error_result_class()(
+                    e, traceback=tb_info, row=row, number=kwargs["row_number"]
+                )
+            )
 
         return row_result
 
@@ -788,8 +793,12 @@ def import_data(
         :param use_transactions: If ``True`` the import process will be processed
             inside a transaction.
 
-        :param collect_failed_rows: If ``True`` the import process will collect
-            failed rows.
+        :param collect_failed_rows:
+          If ``True`` the import process will create a new dataset object comprising
+          failed rows and errors.
+          This can be useful for debugging purposes but will cause higher memory usage
+          for larger datasets.
+          See :attr:`~import_export.results.Result.failed_dataset`.
 
         :param rollback_on_validation_errors: If both ``use_transactions`` and
           ``rollback_on_validation_errors`` are set to ``True``, the import process will
@@ -921,16 +930,21 @@ def import_data_inner(
             result.increment_row_result_total(row_result)
 
             if row_result.errors:
+                result.append_error_row(i, row, row_result.errors)
                 if collect_failed_rows:
                     result.append_failed_row(row, row_result.errors[0])
                 if raise_errors:
-                    raise row_result.errors[-1].error
+                    raise exceptions.ImportError(
+                        row_result.errors[-1].error, number=i, row=row
+                    )
             elif row_result.validation_error:
                 result.append_invalid_row(i, row, row_result.validation_error)
                 if collect_failed_rows:
                     result.append_failed_row(row, row_result.validation_error)
                 if raise_errors:
-                    raise row_result.validation_error
+                    raise exceptions.ImportError(
+                        row_result.validation_error, number=i, row=row
+                    )
             if (
                 row_result.import_type != RowResult.IMPORT_TYPE_SKIP
                 or self._meta.report_skipped
@@ -1112,7 +1126,7 @@ def _check_import_id_fields(self, headers):
                 missing_fields.append(col)
 
         if missing_fields:
-            raise FieldError(
+            raise exceptions.FieldError(
                 _(
                     "The following import_id_fields are not present in the dataset: %s"
                     % ", ".join(missing_fields)
diff --git a/import_export/results.py b/import_export/results.py
--- a/import_export/results.py
+++ b/import_export/results.py
@@ -6,10 +6,11 @@
 
 
 class Error:
-    def __init__(self, error, traceback=None, row=None):
+    def __init__(self, error, traceback=None, row=None, number=None):
         self.error = error
         self.traceback = traceback
         self.row = row
+        self.number = number
 
 
 class RowResult:
@@ -148,13 +149,28 @@ def error_count(self):
         return count
 
 
+class ErrorRow:
+    """A row that resulted in one or more errors being raised during import."""
+
+    def __init__(self, number, errors):
+        #: The row number
+        self.number = number
+        #: A list of errors associated with the row
+        self.errors = errors
+
+
 class Result:
     def __init__(self, *args, **kwargs):
         super().__init__()
         self.base_errors = []
         self.diff_headers = []
-        self.rows = []  # RowResults
-        self.invalid_rows = []  # InvalidRow
+        #: The rows associated with the result.
+        self.rows = []
+        #: The collection of rows which had validation errors.
+        self.invalid_rows = []
+        #: The collection of rows which had generic errors.
+        self.error_rows = []
+        #: A custom Dataset containing only failed rows and associated errors.
         self.failed_dataset = Dataset()
         self.totals = OrderedDict(
             [
@@ -197,6 +213,9 @@ def append_invalid_row(self, number, row, validation_error):
             InvalidRow(number=number, validation_error=validation_error, values=values)
         )
 
+    def append_error_row(self, number, row, errors):
+        self.error_rows.append(ErrorRow(number=number, errors=errors))
+
     def increment_row_result_total(self, row_result):
         if row_result.import_type:
             self.totals[row_result.import_type] += 1


Test patch:
diff --git a/tests/core/tests/test_resources/test_bulk_operations.py b/tests/core/tests/test_resources/test_bulk_operations.py
--- a/tests/core/tests/test_resources/test_bulk_operations.py
+++ b/tests/core/tests/test_resources/test_bulk_operations.py
@@ -6,7 +6,7 @@
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 
-from import_export import fields, resources, widgets
+from import_export import exceptions, fields, resources, widgets
 from import_export.instance_loaders import ModelInstanceLoader
 
 
@@ -246,7 +246,7 @@ class Meta:
                 batch_size = 100
 
         resource = _BookResource()
-        with self.assertRaises(ValidationError):
+        with self.assertRaises(exceptions.ImportError):
             resource.import_data(self.dataset, raise_errors=True)
 
     @mock.patch("core.models.Book.objects.bulk_create")
@@ -457,7 +457,7 @@ class Meta:
                 use_bulk = True
 
         resource = _BookResource()
-        with self.assertRaises(ValidationError) as raised_exc:
+        with self.assertRaises(exceptions.ImportError) as raised_exc:
             resource.import_data(self.dataset, raise_errors=True)
             self.assertEqual(e, raised_exc)
 
diff --git a/tests/core/tests/test_resources/test_resources.py b/tests/core/tests/test_resources/test_resources.py
--- a/tests/core/tests/test_resources/test_resources.py
+++ b/tests/core/tests/test_resources/test_resources.py
@@ -562,7 +562,7 @@ def test_row_result_raise_errors(self):
         # 'user' is a required field, the database will raise an error.
         row = [None, None]
         dataset = tablib.Dataset(row, headers=headers)
-        with self.assertRaises(IntegrityError):
+        with self.assertRaises(exceptions.ImportError) as exc:
             resource.import_data(
                 dataset,
                 dry_run=True,
@@ -570,13 +570,17 @@ def test_row_result_raise_errors(self):
                 raise_errors=True,
             )
 
+        row_error = exc.exception
+        self.assertEqual(1, row_error.number)
+        self.assertEqual({"id": None, "user": None}, row_error.row)
+
     @ignore_widget_deprecation_warning
     def test_collect_failed_rows_validation_error(self):
         resource = ProfileResource()
         row = ["1"]
         dataset = tablib.Dataset(row, headers=["id"])
         with mock.patch(
-            "import_export.resources.Field.save", side_effect=ValidationError("fail!")
+            "import_export.resources.Field.save", side_effect=Exception("fail!")
         ):
             result = resource.import_data(
                 dataset,
@@ -590,9 +594,7 @@ def test_collect_failed_rows_validation_error(self):
             len(result.failed_dataset),
         )
         self.assertEqual("1", result.failed_dataset.dict[0]["id"])
-        self.assertEqual(
-            "{'__all__': ['fail!']}", result.failed_dataset.dict[0]["Error"]
-        )
+        self.assertEqual("fail!", result.failed_dataset.dict[0]["Error"])
 
     @ignore_widget_deprecation_warning
     def test_row_result_raise_ValidationError(self):
@@ -602,7 +604,9 @@ def test_row_result_raise_ValidationError(self):
         with mock.patch(
             "import_export.resources.Field.save", side_effect=ValidationError("fail!")
         ):
-            with self.assertRaisesRegex(ValidationError, "{'__all__': \\['fail!'\\]}"):
+            with self.assertRaisesRegex(
+                exceptions.ImportError, "{'__all__': \\['fail!'\\]}"
+            ):
                 resource.import_data(
                     dataset,
                     dry_run=True,
@@ -1170,9 +1174,9 @@ def before_import(self, dataset, **kwargs):
                 raise Exception("This is an invalid dataset")
 
         resource = B()
-        with self.assertRaises(Exception) as cm:
+        with self.assertRaises(exceptions.ImportError) as cm:
             resource.import_data(self.dataset, raise_errors=True)
-        self.assertEqual("This is an invalid dataset", cm.exception.args[0])
+        self.assertEqual("This is an invalid dataset", cm.exception.error.args[0])
 
     @ignore_widget_deprecation_warning
     def test_after_import_raises_error(self):
@@ -1183,9 +1187,9 @@ def after_import(
                 raise Exception("This is an invalid dataset")
 
         resource = B()
-        with self.assertRaises(Exception) as cm:
+        with self.assertRaises(exceptions.ImportError) as cm:
             resource.import_data(self.dataset, raise_errors=True)
-        self.assertEqual("This is an invalid dataset", cm.exception.args[0])
+        self.assertEqual("This is an invalid dataset", cm.exception.error.args[0])
 
     def test_link_to_nonexistent_field(self):
         with self.assertRaises(FieldDoesNotExist) as cm: