Repository: Deepwalker/trafaret

Issue:
Hex and URLSafe validators
Would be nice to have a couple more String validators that validated the string as e.g. hex or url safe. Matching for example, the output you'd get from `secrets.token_hex()` and `secrets.token_urlsafe()`.

Discussion:

Patch:
diff --git a/trafaret/__init__.py b/trafaret/__init__.py
index 13a10af..43eb57c 100644
--- a/trafaret/__init__.py
+++ b/trafaret/__init__.py
@@ -51,10 +51,12 @@ from .numeric import (
     ToInt,
     ToDecimal,
 )
-from .regexp import Regexp, RegexpRaw
+from .regexp import Regexp, RegexpRaw, RegexpString
 from .internet import (
     URL,
+    URLSafe,
     Email,
+    Hex,
     IPv4,
     IPv6,
     IP,
@@ -104,8 +106,11 @@ __all__ = (
 
     "Regexp",
     "RegexpRaw",
+    "RegexpString",
     "URL",
+    "URLSafe",
     "Email",
+    "Hex",
     "IPv4",
     "IPv6",
     "IP",
diff --git a/trafaret/internet.py b/trafaret/internet.py
index 273bd9a..5ae73d8 100644
--- a/trafaret/internet.py
+++ b/trafaret/internet.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 
 import re
-from .regexp import Regexp
+from .regexp import Regexp, RegexpString
 from .base import String, FromBytes, OnError, WithRepr
 from .lib import py3
 from . import codes
@@ -41,7 +41,7 @@ to_str = OnError(FromBytes('utf-8') | String(), 'value is not a string', code=co
 
 email_regexp_trafaret = OnError(to_str & Regexp(EMAIL_REGEXP), 'value is not a valid email address')
 email_trafaret = (email_regexp_trafaret | (to_str & email_idna_encode & email_regexp_trafaret))
-Email = to_str & String(allow_blank=True) & OnError(
+Email = to_str & OnError(
     String(max_length=MAX_EMAIL_LEN) & email_trafaret,
     'value is not a valid email address',
     code=codes.IS_NOT_VALID_EMAIL,
@@ -49,6 +49,21 @@ Email = to_str & String(allow_blank=True) & OnError(
 Email = WithRepr(Email, '<Email>')
 
 
+class Hex(RegexpString):
+    regex = r'^[0-9a-f]*$'
+    str_method = "lower"
+
+    def __repr__(self):
+        return '<Hex>'
+
+
+class URLSafe(RegexpString):
+    regex = r'^[0-9A-Za-z-_]*$'
+
+    def __repr__(self):
+        return '<URLSafe>'
+
+
 URL_REGEXP = re.compile(
     r'^(?:http|ftp)s?://'  # http:// or https://
     r'(?:\S+(?::\S*)?@)?'  # user and password
diff --git a/trafaret/regexp.py b/trafaret/regexp.py
index 653c39b..26c7e91 100644
--- a/trafaret/regexp.py
+++ b/trafaret/regexp.py
@@ -1,5 +1,5 @@
 import re
-from .base import Trafaret
+from .base import Trafaret, String
 from .lib import STR_TYPES
 from . import codes
 
@@ -29,3 +29,22 @@ class RegexpRaw(Trafaret):
 class Regexp(RegexpRaw):
     def check_and_return(self, value):
         return super(Regexp, self).check_and_return(value).group()
+
+
+class RegexpString(String, Regexp):
+    __slots__ = ()
+    # regex: str
+    str_method = None
+
+    def __init__(self, *args, **kwargs):
+        String.__init__(self, *args, **kwargs)
+        Regexp.__init__(self, self.regex)
+
+    def check_and_return(self, value):
+        str_value = String.check_and_return(self, value)
+        if self.str_method is not None:
+            str_value = getattr(str_value, self.str_method)()
+        return Regexp.check_and_return(self, str_value)
+
+    def __repr__(self):
+        return '<RegexpString "%s">' % self.regex


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index 8ca7cae..0ca167b 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -761,6 +761,25 @@ class TestRegexpTrafaret:
         assert repr(t.RegexpRaw('.*(cat).*')) == '<Regexp ".*(cat).*">'
 
 
+class TestRegexpString:
+    def test_regexpstring(self):
+        class R(t.RegexpString):
+            regex = r'^A?$'
+            str_method = 'upper'
+
+        assert R().check('a') == 'A'
+
+        res = extract_error(R(), 'b')
+        assert res == 'does not match pattern ^A?$'
+
+        res = extract_error(R(), '')
+        assert res == 'blank value is not allowed'
+
+        assert R(allow_blank=True).check('') == ''
+
+        assert repr(R()) == '<RegexpString "^A?$">'
+
+
 class TestTrafaretMeta:
     def test_meta(self):
         res = (t.ToInt() >> (lambda x: x * 2) >> (lambda x: x * 3)).check(1)
diff --git a/tests/test_internet.py b/tests/test_internet.py
index 1c6147d..de2d808 100644
--- a/tests/test_internet.py
+++ b/tests/test_internet.py
@@ -100,6 +100,58 @@ class TestEmailTrafaret:
             t.Email.check(b'ahha@\xd0\xbf\xd1\x80\xd0\xb8\xd0\xbc\xd0\xb5\xd1\x80.\xd1\xd1\x84')
 
 
+class TestHexTrafaret:
+    def test_hex(self):
+        res = t.Hex().check('bbd3ec7776d5684d87')
+        assert res == 'bbd3ec7776d5684d87'
+        res = t.Hex().check('47CCB8AEEC972')
+        assert res == '47ccb8aeec972'
+        res = extract_error(t.Hex(), 'apple')
+        assert res == 'does not match pattern ^[0-9a-f]*$'
+        res = extract_error(t.Hex(), '')
+        assert res == 'blank value is not allowed'
+        res = t.Hex(allow_blank=True).check('')
+        assert res == ''
+        res = t.Hex(min_length=2, max_length=3).check('12a')
+        assert res == '12a'
+        res = extract_error(t.Hex(min_length=2, max_length=6), '1')
+        assert res == 'String is shorter than 2 characters'
+        res = extract_error(t.Hex(min_length=2, max_length=6), '1234567')
+        assert res == 'String is longer than 6 characters'
+
+        res = t.Hex(min_length=0, max_length=6, allow_blank=True).check('abc')
+        assert res == 'abc'
+
+    def test_repr(self):
+        res = t.Hex()
+        assert repr(res) == '<Hex>'
+
+
+class TestURLSafeTrafaret:
+    def test_urlsafe(self):
+        res = t.URLSafe().check('sB3ny_Vmu-C')
+        assert res == 'sB3ny_Vmu-C'
+        res = extract_error(t.URLSafe(), 'app/le')
+        assert res == 'does not match pattern ^[0-9A-Za-z-_]*$'
+        res = extract_error(t.URLSafe(), '')
+        assert res == 'blank value is not allowed'
+        res = t.URLSafe(allow_blank=True).check('')
+        assert res == ''
+        res = t.URLSafe(min_length=2, max_length=3).check('1-z')
+        assert res == '1-z'
+        res = extract_error(t.URLSafe(min_length=2, max_length=6), '_')
+        assert res == 'String is shorter than 2 characters'
+        res = extract_error(t.URLSafe(min_length=2, max_length=6), '1234567')
+        assert res == 'String is longer than 6 characters'
+
+        res = t.URLSafe(min_length=0, max_length=6, allow_blank=True).check('a_c')
+        assert res == 'a_c'
+
+    def test_repr(self):
+        res = t.URLSafe()
+        assert repr(res) == '<URLSafe>'
+
+
 def test_ipv4(valid_ips_v4, invalid_ips_v4):
     ip = t.IPv4