Repository: di/vladiate

Issue:
Row-level validation
Is there a recommended way to do row-level validation? I have a work in progress / proof of concept branch to add the kind of logic I'm looking to use at https://github.com/jonafato/vladiate/tree/row-level-validators. My primary use cases here are the ability to validate the length of a row (`csv.DictReader` is very permissive here and reads rows of variable length) and to validate pairs of fields together. This could be conceptually similar to other validation libraries like Django's [form validation](https://docs.djangoproject.com/en/4.2/ref/forms/validation/#cleaning-and-validating-fields-that-depend-on-each-other).

If this feature seems useful to you, I'm happy to open a PR and fill in the rest of the implementation.

Discussion:
jonafato: I've just added the missing logging, I think this should be good to go.

Patch:
diff --git a/README.rst b/README.rst
index 07191ef..b349a7c 100644
--- a/README.rst
+++ b/README.rst
@@ -278,6 +278,17 @@ Vladiate comes with a few common validators built-in:
 
   Always passes validation. Used to explicity ignore a given column.
 
+*class* ``RowValidator``
+
+  Generic row validator. Should be subclassed by any custom validators. Not
+  to be used directly.
+
+*class* ``RowLengthValidator``
+
+  Validates that each row has the expected number of fields. The expected
+  number of fields is inferred from the CSV header row read by
+  ``csv.DictReader``.
+
 Built-in Input Types
 ^^^^^^^^^^^^^^^^^^^^
 
diff --git a/vladiate/validators.py b/vladiate/validators.py
index bc4cc49..18b828c 100644
--- a/vladiate/validators.py
+++ b/vladiate/validators.py
@@ -224,6 +224,61 @@ class Ignore(Validator):
         pass
 
 
+class RowValidator(object):
+    """Generic RowValidator class"""
+
+    def __init__(self):
+        self.fail_count = 0
+
+    @property
+    def bad(self):
+        """Return something containing the "bad" fields."""
+        raise NotImplementedError
+
+    def validate(self, row):
+        """Validate the given row."""
+        raise NotImplementedError
+
+
+class RowLengthValidator(Validator):
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self.invalid_rows = []
+
+    def validate(self, row):
+        # `csv.DictReader` uses its `restkey` attributes to store values
+        # left over after consuming the expected number of values based
+        # on the header row. If the row contains `None` here, that means
+        # the row was longer than expected. Note that this currently
+        # only handles the default value of `None` and does not have
+        # access to the `DictReader` instance to lookup the configured
+        # `restkey` attribute. `vladiate` does not modify this attribute
+        # as of the time of writing this validator, so we use the
+        # default of `None` in our checks. `None` is not an expected
+        # valid key for standard `DictReader` use.
+        if None in row.keys():
+            self.invalid_rows.append(row)
+            expected_length = len(row) - 1
+            length = len(row) + len(row[None]) - 1
+            raise ValidationException(
+                f"Expected {expected_length} fields, got {length}"
+            )
+
+        # Similarly, there is a `csv.DictReader.restval` attribute that
+        # handles the case where there are fewer than expected rows.
+        if None in row.values():
+            self.invalid_rows.append(row)
+            expected_length = len(row)
+            length = len([value for value in row.values() if value is not None])
+            raise ValidationException(
+                f"Expected {expected_length} fields, got {length}"
+            )
+
+    @property
+    def bad(self):
+        return self.invalid_rows
+
+
 def _stringify_set(a_set, max_len, max_sort_size=8192):
     """Stringify `max_len` elements of `a_set` and count the remainings
 
diff --git a/vladiate/vlad.py b/vladiate/vlad.py
index 3e060c6..367e40c 100644
--- a/vladiate/vlad.py
+++ b/vladiate/vlad.py
@@ -15,13 +15,16 @@ class Vlad(object):
         delimiter=None,
         ignore_missing_validators=False,
         quiet=False,
+        row_validators=[],
     ):
         self.logger = logs.logger
         self.failures = defaultdict(lambda: defaultdict(list))
+        self.row_failures = defaultdict(list)
         self.missing_validators = None
         self.missing_fields = None
         self.source = source
         self.validators = validators or getattr(self, "validators", {})
+        self.row_validators = row_validators or getattr(self, "row_validators", [])
         self.delimiter = delimiter or getattr(self, "delimiter", ",")
         self.line_count = 0
         self.ignore_missing_validators = ignore_missing_validators
@@ -37,6 +40,11 @@ class Vlad(object):
         )
 
     def _log_debug_failures(self):
+        for line, errors in self.row_failures.items():
+            self.logger.debug("\nFailure on line number {}".format(line))
+            for error in errors:
+                self.logger.debug("    {}".format(error))
+
         for field_name, field_failure in self.failures.items():
             self.logger.debug('\nFailure on field: "{}":'.format(field_name))
             for i, (row, errors) in enumerate(field_failure.items()):
@@ -45,6 +53,29 @@ class Vlad(object):
                     self.logger.debug("    {}".format(error))
 
     def _log_validator_failures(self):
+        for validator in self.row_validators:
+            if validator.bad:
+                self.logger.error(
+                    " {} failed {} time(s) ({:.1%})".format(
+                        validator.__class__.__name__,
+                        validator.fail_count,
+                        validator.fail_count / self.line_count,
+                    )
+                )
+                try:
+                    # If validator.bad is iterable, it contains the rows
+                    # which caused it to fail
+                    invalid = list(validator.bad)
+                    shown = ["'{}'".format(row) for row in invalid[:99]]
+                    hidden = ["'{}'".format(row) for row in invalid[99:]]
+                    self.logger.error(
+                        "   Invalid rows: \n{}".format("    \n".join(shown))
+                    )
+                    if hidden:
+                        self.logger.error("    ({} more suppressed".format(len(hidden)))
+                except TypeError:
+                    pass
+
         for field_name, validators_list in self.validators.items():
             for validator in validators_list:
                 if validator.bad:
@@ -117,6 +148,15 @@ class Vlad(object):
 
         for line, row in enumerate(reader):
             self.line_count += 1
+
+            for validator in self.row_validators:
+                try:
+                    validator.validate(row)
+                except ValidationException as e:
+                    self.row_failures[line].append(e)
+                    self.invalid_lines.add(self.line_count)
+                    validator.fail_count += 1
+
             for field_name, field in row.items():
                 if field_name in self.validators:
                     for validator in self.validators[field_name]:
@@ -127,7 +167,7 @@ class Vlad(object):
                             self.invalid_lines.add(self.line_count)
                             validator.fail_count += 1
 
-        if self.failures:
+        if self.failures or self.row_failures:
             self.logger.info("\033[0;31m" + "Failed :(" + "\033[0m")
             self._log_debug_failures()
             self._log_validator_failures()


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index f12b371..c8ab8b9 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -11,6 +11,8 @@ from vladiate.validators import (
     NotEmptyValidator,
     RangeValidator,
     RegexValidator,
+    RowValidator,
+    RowLengthValidator,
     SetValidator,
     UniqueValidator,
     Validator,
@@ -255,6 +257,36 @@ def test_base_class_raises():
         validator.validate(stub(), stub())
 
 
+def test_row_length_validator_works():
+    validator = RowLengthValidator()
+    validator.validate({"Field One": "1", "Field Two": "Two"})
+
+
+@pytest.mark.parametrize(
+    "row",
+    [
+        {"Field One": "1", "Field Two": None},
+        {"Field One": "1", "Field Two": "2", None: ["3", "4"]},
+    ],
+)
+def test_row_length_validator_fails(row):
+    validator = RowLengthValidator()
+    with pytest.raises(ValidationException):
+        validator.validate(row)
+
+    assert validator.bad == [row]
+
+
+def test_base_row_validator_raises():
+    validator = RowValidator()
+
+    with pytest.raises(NotImplementedError):
+        validator.bad
+
+    with pytest.raises(NotImplementedError):
+        validator.validate(stub())
+
+
 @pytest.mark.parametrize(
     "validator_class,args",
     [
diff --git a/tests/test_vlads.py b/tests/test_vlads.py
index 9e667c7..1c41056 100644
--- a/tests/test_vlads.py
+++ b/tests/test_vlads.py
@@ -5,6 +5,7 @@ from vladiate.validators import (
     EmptyValidator,
     FloatValidator,
     NotEmptyValidator,
+    RowLengthValidator,
     SetValidator,
     UniqueValidator,
 )
@@ -59,6 +60,18 @@ def test_validators_in_class_variable_are_used():
     assert TestVlad(source=source).validate()
 
 
+def test_row_validators():
+    source = LocalFile("vladiate/examples/vampires.csv")
+    row_validators = [RowLengthValidator()]
+    validators = {
+        "Column A": [UniqueValidator()],
+        "Column B": [SetValidator(["Vampire", "Not A Vampire"])],
+    }
+    assert Vlad(
+        source=source, row_validators=row_validators, validators=validators
+    ).validate()
+
+
 def test_missing_validators():
     source = LocalFile("vladiate/examples/vampires.csv")