Repository: coverahealth/dataspec

Issue:
`s.bytes` should include regex matching
The Python `re` module allows defining regex patterns on `bytes` types in addition to `str` types, so `s.bytes` should allow defining a regex on bytes.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc15b9a..7339d07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Add `s.dict_tag` as a convenience factory for building mapping specs for which
   the value spec tags are derived automatically from the corresponding dict keys (#52)
 - Add documentation built using Sphinx and hosted on ReadTheDocs (#9)
+- Add a `regex` validator to the `s.bytes` factory (#37)
 
 ### Fixed
 - Fixed a bug where `s(None)` is not a valid alias for `s(type(None))` (#61)
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index d8ed31e..3225790 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -235,6 +235,7 @@ def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
     length: Optional[int] = None,
     minlength: Optional[int] = None,
     maxlength: Optional[int] = None,
+    regex: Union[Pattern, bytes, None] = None,
     conformer: Optional[Conformer] = None,
 ) -> Spec:
     """
@@ -255,6 +256,11 @@ def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
     be raised. If any length value is not an :py:class:`int` a :py:exc:`TypeError`
     will be raised.
 
+    If ``regex`` is specified and is a :py:class:`bytes`, a Regex pattern will be
+    created by :py:func:`re.compile`. If ``regex`` is specified and is a
+    :py:obj:`typing.Pattern`, the supplied pattern will be used. In both cases, the
+    :py:func:`re.fullmatch` will be used to validate input strings.
+
     :param tag: an optional tag for the resulting spec
     :param type_:  a single :py:class:`type` or tuple of :py:class:`type` s which
         will be used to type check input values by the resulting Spec
@@ -264,6 +270,8 @@ def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
         not fewer than ``minlength`` bytes long by :py:func:`len`
     :param maxlength: if specified, the resulting Spec will validate that bytes are
         not longer than ``maxlength`` bytes long by :py:func:`len`
+    :param regex: if specified, the resulting Spec will validate that strings match
+        the ``regex`` pattern using :py:func:`re.fullmatch`
     :param conformer: an optional conformer for the value
     :return: a Spec which validates bytes and bytearrays
     """
@@ -330,6 +338,17 @@ def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
                 "Cannot define a spec with minlength greater than maxlength"
             )
 
+    if regex is not None:
+        _pattern = regex if isinstance(regex, Pattern) else re.compile(regex)
+
+        @pred_to_validator(
+            "Bytes '{value}' does match regex '{regex}'", complement=True, regex=regex
+        )
+        def bytes_matches_regex(s: str) -> bool:
+            return bool(_pattern.fullmatch(s))
+
+        validators.append(bytes_matches_regex)
+
     return ValidatorSpec.from_validators(
         tag or "bytes", *validators, conformer=conformer
     )


Test patch:
diff --git a/tests/test_factories.py b/tests/test_factories.py
index 6621bd8..ff211e2 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -346,6 +346,36 @@ class TestBytesSpecValidation:
         def test_is_not_maxlength(self, maxlength_spec: Spec, v):
             assert not maxlength_spec.is_valid(v)
 
+    class TestRegexSpec:
+        @pytest.fixture(params=(b"\d{5}(-\d{4})?", re.compile(b"\d{5}(-\d{4})?")))
+        def zipcode_spec(self, request) -> Spec:
+            return s.bytes(regex=request.param)
+
+        @pytest.mark.parametrize(
+            "v", [b"10017", b"10017-3332", b"37779", b"37779-2770", b"00000"]
+        )
+        def test_is_zipcode(self, zipcode_spec: Spec, v):
+            assert zipcode_spec.is_valid(v)
+
+        @pytest.mark.parametrize(
+            "v",
+            [
+                None,
+                25,
+                3.14,
+                [],
+                set(),
+                "abcdef",
+                "abcdefg",
+                "100017",
+                "10017-383",
+                b"100017",
+                b"10017-383",
+            ],
+        )
+        def test_is_not_zipcode(self, zipcode_spec: Spec, v):
+            assert not zipcode_spec.is_valid(v)
+
     def test_minlength_and_maxlength_agreement(self):
         s.bytes(minlength=10, maxlength=10)
         s.bytes(minlength=8, maxlength=10)