Repository: encode/typesystem

Issue:
better error messages for regular expression validation
string fields can have a `pattern` for validation, and since `re.search()` is used under the hood, the pattern can be either a regular expression as a string or a compiled regular expression (`re.compile(...)` is cached and can have flags).

so far so good, but the error message uses this:

```
        "pattern": "Must match the pattern /{pattern}/.",
```

the `str()` conversion for compiled regexes looks like code:

```
>>> my_regex = re.compile('foo')

>>> f"oops: {my_regex}"
"oops: re.compile('foo')"
```

but the `.pattern` attribute is a string:

```
>>> f"oops: {my_regex.pattern}"
'oops: foo'
```

so, perhaps the error message for regular expressions could be improved. :deciduous_tree: 

Discussion:

Patch:
diff --git a/docs/fields.md b/docs/fields.md
index 0f694b0..5ce383b 100644
--- a/docs/fields.md
+++ b/docs/fields.md
@@ -58,7 +58,7 @@ For example: `username = typesystem.String(max_length=100)`
 * `trim_whitespace` - A boolean indicating if leading/trailing whitespace should be removed on validation. **Default: `True`**
 * `max_length` - A maximum number of characters that valid input stings may contain. **Default: `None`**
 * `min_length` - A minimum number of characters that valid input stings may contain. **Default: `None`**
-* `pattern` - A string to be used as a regex that must match. Eg. `patern="^[A-Za-z]+$"` **Default: `None`**
+* `pattern` - A regular expression that must match. This can be either a string or a compiled regular expression. E.g. `pattern="^[A-Za-z]+$"` **Default: `None`**
 * `format` - A string used to indicate a semantic type, such as `"email"`, `"url"`, or `"color"`. **Default: `None`**
 
 ### Text
@@ -97,8 +97,8 @@ provide more precise behaviour.
 * `maximum` - A number representing the maximum allowed value. Inputs must be less than or equal to this to validate. **Default: `None`**
 * `exclusive_minimum` - A number representing an exclusive minimum. Inputs must be greater than this to validate. **Default: `None`**
 * `exclusive_maximum` - A number representing an exclusive maximum. Inputs must be less than this to validate. **Default: `None`**
-* `precision` - A string representing the decimal precision to truncate input with. Eg. `precision="0.001"`. **Default: `None`**
-* `multiple_of` - A number giving a value that inputs must be a strict multiple of in order to validate. Eg. `multiple_of=2` will only validate even integers. **Default: `None`**
+* `precision` - A string representing the decimal precision to truncate input with. E.g. `precision="0.001"`. **Default: `None`**
+* `multiple_of` - A number giving a value that inputs must be a strict multiple of in order to validate. E.g. `multiple_of=2` will only validate even integers. **Default: `None`**
 
 ### Integer
 
diff --git a/typesystem/fields.py b/typesystem/fields.py
index 3e3d656..8c5fffc 100644
--- a/typesystem/fields.py
+++ b/typesystem/fields.py
@@ -78,6 +78,25 @@ class Field:
         return self.errors[code].format(**self.__dict__)
 
 
+def normalize_regex(
+    pattern: typing.Union[str, typing.Pattern] = None
+) -> typing.Tuple[typing.Optional[str], typing.Optional[typing.Pattern]]:
+    """
+    Normalise and validate a regular expression-like input.
+
+    Returns a 2-tuple with a pattern string and a compiled regular expression.
+    """
+    if pattern is None:
+        return (None, None)
+
+    if isinstance(pattern, str):
+        pattern_regex = re.compile(pattern)
+    else:
+        pattern_regex = pattern
+
+    return pattern_regex.pattern, pattern_regex
+
+
 class String(Field):
     errors = {
         "type": "Must be a string.",
@@ -96,7 +115,7 @@ class String(Field):
         trim_whitespace: bool = True,
         max_length: int = None,
         min_length: int = None,
-        pattern: str = None,
+        pattern: typing.Union[str, typing.Pattern] = None,
         format: str = None,
         **kwargs: typing.Any,
     ) -> None:
@@ -104,7 +123,7 @@ class String(Field):
 
         assert max_length is None or isinstance(max_length, int)
         assert min_length is None or isinstance(min_length, int)
-        assert pattern is None or isinstance(pattern, str)
+        assert pattern is None or isinstance(pattern, (str, typing.Pattern))
         assert format is None or isinstance(format, str)
 
         if allow_blank and not self.has_default():
@@ -114,7 +133,7 @@ class String(Field):
         self.trim_whitespace = trim_whitespace
         self.max_length = max_length
         self.min_length = min_length
-        self.pattern = pattern
+        self.pattern, self.pattern_regex = normalize_regex(pattern)
         self.format = format
 
     def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any:
@@ -151,8 +170,8 @@ class String(Field):
             if len(value) > self.max_length:
                 raise self.validation_error("max_length")
 
-        if self.pattern is not None:
-            if not re.search(self.pattern, value):
+        if self.pattern_regex is not None:
+            if not self.pattern_regex.search(value):
                 raise self.validation_error("pattern")
 
         if self.format in FORMATS:
diff --git a/typesystem/json_schema.py b/typesystem/json_schema.py
index 1360799..fbc1529 100644
--- a/typesystem/json_schema.py
+++ b/typesystem/json_schema.py
@@ -1,3 +1,4 @@
+import re
 import typing
 
 from typesystem.composites import AllOf, IfThenElse, NeverMatch, Not, OneOf
@@ -318,8 +319,14 @@ def to_json_schema(
             data["minLength"] = field.min_length or 1
         if field.max_length is not None:
             data["maxLength"] = field.max_length
-        if field.pattern is not None:
-            data["pattern"] = field.pattern
+        if field.pattern_regex is not None:
+            if field.pattern_regex.flags != re.RegexFlag.UNICODE:
+                flags = re.RegexFlag(field.pattern_regex.flags)
+                raise ValueError(
+                    f"Cannot convert regular expression with non-standard flags "
+                    f"to JSON schema: {flags!s}"
+                )
+            data["pattern"] = field.pattern_regex.pattern
         if field.format is not None:
             data["format"] = field.format
         return data


Test patch:
diff --git a/tests/test_fields.py b/tests/test_fields.py
index cd7308e..866322c 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -1,5 +1,6 @@
 import datetime
 import decimal
+import re
 
 from typesystem.base import Message, ValidationError
 from typesystem.fields import (
@@ -88,6 +89,10 @@ def test_string():
         text="Must match the pattern /^[abc]*$/.", code="pattern"
     )
 
+    validator = String(pattern=re.compile("ABC", re.IGNORECASE))
+    value, error = validator.validate_or_error("abc")
+    assert value == "abc"
+
     validator = String()
     value, error = validator.validate_or_error(" ")
     assert error == ValidationError(text="Must not be blank.", code="blank")
diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py
index 3aae745..c5e2736 100644
--- a/tests/test_json_schema.py
+++ b/tests/test_json_schema.py
@@ -1,5 +1,6 @@
 import json
 import os
+import re
 
 import pytest
 
@@ -144,3 +145,15 @@ def test_to_json_schema_invalid_field():
     field = CustomField()
     with pytest.raises(TypeError):
         to_json_schema(field)
+
+
+def test_to_json_schema_complex_regular_expression():
+    field = typesystem.String(pattern=re.compile("foo", re.IGNORECASE | re.VERBOSE))
+    with pytest.raises(ValueError) as exc_info:
+        to_json_schema(field)
+
+    expected = (
+        "Cannot convert regular expression with non-standard flags "
+        "to JSON schema: RegexFlag."
+    )
+    assert str(exc_info.value).startswith(expected)