Repository: samuelcolvin/pydantic (python)

Issue:
Adding "anystr_lower" to config
# Feature Request

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.6.1
            pydantic compiled: True
                 install path: ***
               python version: 3.8.0 (v3.8.0:fa919fdf25, Oct 14 2019, 10:23:27)  [Clang 6.0 (clang-600.0.57)]
                     platform: macOS-10.15.7-x86_64-i386-64bit
     optional deps. installed: ['typing-extensions', 'email-validator']

```
Just like there is "anystr_strip_whitespace" in the config, which runs .strip() on any string value, I would like to add "anystr_lower" which would run .lower() on any string value.

I just find myself use .lower() on the strings in many validators in places in my code, so I thought it would be useful to add it to the config, I assume that was the use-case of "anystr_strip_whitespace" as well.

If this is fine I'll gladly implement it myself, looks like it's a small change.


Pull request:
Feature/add anystr lower to config
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->
<!-- See https://pydantic-docs.helpmanual.io/contributing/ for help on Contributing -->

## Change Summary

This change adds the anystr_lower field to the BaseConfig class, allowing users to ensure all string fields are kept as lower case.

## Related issue number

fix #2134

## Checklist

* [x] Unit tests for the changes exist
* [x] Tests pass on CI and coverage remains at 100%
* [x] Documentation reflects the changes where applicable
* [x] `changes/<pull request or issue id>-<github username>.md` file added describing change

Patch:
diff --git a/changes/2134-tayoogunbiyi.md b/changes/2134-tayoogunbiyi.md
new file mode 100644
index 000000000..5779fc9db
--- /dev/null
+++ b/changes/2134-tayoogunbiyi.md
@@ -0,0 +1,1 @@
+added `Config.anystr_lower` and `to_lower` kwarg to `constr` and `conbytes`.
diff --git a/changes/2198-Midnighter.md b/changes/2198-Midnighter.md
new file mode 100644
index 000000000..a09b1f768
--- /dev/null
+++ b/changes/2198-Midnighter.md
@@ -0,0 +1,1 @@
+Add a `FrozenSet[str]` type annotation to the `allowed_schemes` argument on the `strict_url` field type.
\ No newline at end of file
diff --git a/docs/examples/types_constrained.py b/docs/examples/types_constrained.py
index 53addddb1..eb258c3f9 100644
--- a/docs/examples/types_constrained.py
+++ b/docs/examples/types_constrained.py
@@ -22,9 +22,11 @@ from pydantic import (
 
 
 class Model(BaseModel):
+    lower_bytes: conbytes(to_lower=True)
     short_bytes: conbytes(min_length=2, max_length=10)
     strip_bytes: conbytes(strip_whitespace=True)
 
+    lower_str: constr(to_lower=True)
     short_str: constr(min_length=2, max_length=10)
     regex_str: constr(regex=r'^apple (pie|tart|sandwich)$')
     strip_str: constr(strip_whitespace=True)
diff --git a/docs/usage/model_config.md b/docs/usage/model_config.md
index f847ad8c0..24219c951 100644
--- a/docs/usage/model_config.md
+++ b/docs/usage/model_config.md
@@ -8,6 +8,9 @@ Options:
 **`anystr_strip_whitespace`**
 : whether to strip leading and trailing whitespace for str & byte types (default: `False`)
 
+**`anystr_lower`**
+: whether to make all characters lowercase for str & byte types (default: `False`)
+
 **`min_anystr_length`**
 : the min length for str & byte types (default: `0`)
 
diff --git a/docs/usage/types.md b/docs/usage/types.md
index 7f1c4d301..808ad4bd9 100644
--- a/docs/usage/types.md
+++ b/docs/usage/types.md
@@ -722,6 +722,25 @@ _(This script is complete, it should run "as is")_
 
 Where `Field` refers to the [field function](schema.md#field-customisation).
 
+### Arguments to `constr`
+The following arguments are available when using the `constr` type function
+
+- `strip_whitespace: bool = False`: removes leading and trailing whitespace
+- `to_lower: bool = False`: turns all characters to lowercase
+- `strict: bool = False`: controls type coercion
+- `min_length: int = None`: minimum length of the string
+- `max_length: int = None`: maximum length of the string
+- `curtail_length: int = None`: shrinks the string length to the set value when it is longer than the set value
+- `regex: str = None`: regex to validate the string against
+
+### Arguments to `conbytes`
+The following arguments are available when using the `conbytes` type function
+
+- `strip_whitespace: bool = False`: removes leading and trailing whitespace
+- `to_lower: bool = False`: turns all characters to lowercase
+- `min_length: int = None`: minimum length of the byte string
+- `max_length: int = None`: maximum length of the byte string
+
 ## Strict Types
 
 You can use the `StrictStr`, `StrictBytes`, `StrictInt`, `StrictFloat`, and `StrictBool` types 
diff --git a/pydantic/_hypothesis_plugin.py b/pydantic/_hypothesis_plugin.py
index 6de9a579b..1cfa7c13b 100644
--- a/pydantic/_hypothesis_plugin.py
+++ b/pydantic/_hypothesis_plugin.py
@@ -59,7 +59,7 @@ else:
         try:
             email_validator.validate_email(s, check_deliverability=False)
             return True
-        except email_validator.EmailNotValidError:
+        except email_validator.EmailNotValidError:  # pragma: no cover
             return False
 
     # Note that these strategies deliberately stay away from any tricky Unicode
@@ -217,7 +217,7 @@ def resolves(
 def resolve_json(cls):  # type: ignore[no-untyped-def]
     try:
         inner = st.none() if cls.inner_type is None else st.from_type(cls.inner_type)
-    except Exception:
+    except Exception:  # pragma: no cover
         finite = st.floats(allow_infinity=False, allow_nan=False)
         inner = st.recursive(
             base=st.one_of(st.none(), st.booleans(), st.integers(), finite, st.text()),
diff --git a/pydantic/main.py b/pydantic/main.py
index cefdceeb8..6eb98457c 100644
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -106,6 +106,7 @@ class Extra(str, Enum):
 
 class BaseConfig:
     title = None
+    anystr_lower = False
     anystr_strip_whitespace = False
     min_anystr_length = None
     max_anystr_length = None
diff --git a/pydantic/networks.py b/pydantic/networks.py
index d5d74794b..edace1fbd 100644
--- a/pydantic/networks.py
+++ b/pydantic/networks.py
@@ -9,7 +9,21 @@ from ipaddress import (
     _BaseAddress,
     _BaseNetwork,
 )
-from typing import TYPE_CHECKING, Any, Dict, Generator, Optional, Pattern, Set, Tuple, Type, Union, cast, no_type_check
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    FrozenSet,
+    Generator,
+    Optional,
+    Pattern,
+    Set,
+    Tuple,
+    Type,
+    Union,
+    cast,
+    no_type_check,
+)
 
 from . import errors
 from .utils import Representation, update_not_none
@@ -310,7 +324,7 @@ def stricturl(
     min_length: int = 1,
     max_length: int = 2 ** 16,
     tld_required: bool = True,
-    allowed_schemes: Optional[Set[str]] = None,
+    allowed_schemes: Optional[Union[FrozenSet[str], Set[str]]] = None,
 ) -> Type[AnyUrl]:
     # use kwargs then define conf in a dict to aid with IDE type hinting
     namespace = dict(
diff --git a/pydantic/types.py b/pydantic/types.py
index b437fe167..846e9ee31 100644
--- a/pydantic/types.py
+++ b/pydantic/types.py
@@ -30,6 +30,7 @@ from .utils import import_string, update_not_none
 from .validators import (
     bytes_validator,
     constr_length_validator,
+    constr_lower,
     constr_strip_whitespace,
     decimal_validator,
     float_validator,
@@ -138,6 +139,7 @@ def _registered(typ: Union[Type[T], 'ConstrainedNumberMeta']) -> Union[Type[T],
 
 class ConstrainedBytes(bytes):
     strip_whitespace = False
+    to_lower = False
     min_length: OptionalInt = None
     max_length: OptionalInt = None
     strict: bool = False
@@ -150,6 +152,7 @@ class ConstrainedBytes(bytes):
     def __get_validators__(cls) -> 'CallableGenerator':
         yield strict_bytes_validator if cls.strict else bytes_validator
         yield constr_strip_whitespace
+        yield constr_lower
         yield constr_length_validator
 
 
@@ -157,9 +160,11 @@ class StrictBytes(ConstrainedBytes):
     strict = True
 
 
-def conbytes(*, strip_whitespace: bool = False, min_length: int = None, max_length: int = None) -> Type[bytes]:
+def conbytes(
+    *, strip_whitespace: bool = False, to_lower: bool = False, min_length: int = None, max_length: int = None
+) -> Type[bytes]:
     # use kwargs then define conf in a dict to aid with IDE type hinting
-    namespace = dict(strip_whitespace=strip_whitespace, min_length=min_length, max_length=max_length)
+    namespace = dict(strip_whitespace=strip_whitespace, to_lower=to_lower, min_length=min_length, max_length=max_length)
     return _registered(type('ConstrainedBytesValue', (ConstrainedBytes,), namespace))
 
 
@@ -246,6 +251,7 @@ def conset(item_type: Type[T], *, min_items: int = None, max_items: int = None)
 
 class ConstrainedStr(str):
     strip_whitespace = False
+    to_lower = False
     min_length: OptionalInt = None
     max_length: OptionalInt = None
     curtail_length: OptionalInt = None
@@ -262,6 +268,7 @@ class ConstrainedStr(str):
     def __get_validators__(cls) -> 'CallableGenerator':
         yield strict_str_validator if cls.strict else str_validator
         yield constr_strip_whitespace
+        yield constr_lower
         yield constr_length_validator
         yield cls.validate
 
@@ -280,6 +287,7 @@ class ConstrainedStr(str):
 def constr(
     *,
     strip_whitespace: bool = False,
+    to_lower: bool = False,
     strict: bool = False,
     min_length: int = None,
     max_length: int = None,
@@ -289,6 +297,7 @@ def constr(
     # use kwargs then define conf in a dict to aid with IDE type hinting
     namespace = dict(
         strip_whitespace=strip_whitespace,
+        to_lower=to_lower,
         strict=strict,
         min_length=min_length,
         max_length=max_length,
diff --git a/pydantic/validators.py b/pydantic/validators.py
index cea25c11a..d12eadc1c 100644
--- a/pydantic/validators.py
+++ b/pydantic/validators.py
@@ -201,6 +201,10 @@ def anystr_strip_whitespace(v: 'StrBytes') -> 'StrBytes':
     return v.strip()
 
 
+def anystr_lower(v: 'StrBytes') -> 'StrBytes':
+    return v.lower()
+
+
 def ordered_dict_validator(v: Any) -> 'AnyOrderedDict':
     if isinstance(v, OrderedDict):
         return v
@@ -479,6 +483,13 @@ def constr_strip_whitespace(v: 'StrBytes', field: 'ModelField', config: 'BaseCon
     return v
 
 
+def constr_lower(v: 'StrBytes', field: 'ModelField', config: 'BaseConfig') -> 'StrBytes':
+    lower = field.type_.to_lower or config.anystr_lower
+    if lower:
+        v = v.lower()
+    return v
+
+
 def validate_json(v: Any, config: 'BaseConfig') -> Any:
     if v is None:
         # pass None through to other validators
@@ -555,6 +566,7 @@ _VALIDATORS: List[Tuple[Type[Any], List[Any]]] = [
         [
             str_validator,
             IfConfig(anystr_strip_whitespace, 'anystr_strip_whitespace'),
+            IfConfig(anystr_lower, 'anystr_lower'),
             IfConfig(anystr_length_validator, 'min_anystr_length', 'max_anystr_length'),
         ],
     ),
@@ -563,6 +575,7 @@ _VALIDATORS: List[Tuple[Type[Any], List[Any]]] = [
         [
             bytes_validator,
             IfConfig(anystr_strip_whitespace, 'anystr_strip_whitespace'),
+            IfConfig(anystr_lower, 'anystr_lower'),
             IfConfig(anystr_length_validator, 'min_anystr_length', 'max_anystr_length'),
         ],
     ),


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c562a8d9d..63fa53ed3 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,7 +10,12 @@ import pytest
 from _pytest.assertion.rewrite import AssertionRewritingHook
 
 # See https://hypothesis.readthedocs.io/en/latest/strategies.html#interaction-with-pytest-cov
-pytest_plugins = ['hypothesis.extra.pytestplugin']
+try:
+    import hypothesis  # noqa
+except ImportError:
+    pytest_plugins = []
+else:
+    pytest_plugins = ['hypothesis.extra.pytestplugin']
 
 
 def _extract_source_code_from_function(function):
diff --git a/tests/test_hypothesis_plugin.py b/tests/test_hypothesis_plugin.py
index ed5257077..d45552045 100644
--- a/tests/test_hypothesis_plugin.py
+++ b/tests/test_hypothesis_plugin.py
@@ -1,11 +1,21 @@
 import typing
 
 import pytest
-from hypothesis import given, strategies as st
 
 import pydantic
 from pydantic.networks import import_email_validator
 
+try:
+    from hypothesis import given, strategies as st
+except ImportError:
+
+    def given(*args, **kwargs):
+        return lambda f: f
+
+    st = type('st', (), {'data': lambda: None})
+
+    pytestmark = pytest.mark.skipif(True, reason='"hypothesis" not installed')
+
 
 def gen_models():
     class MiscModel(pydantic.BaseModel):
diff --git a/tests/test_types.py b/tests/test_types.py
index 672072e10..f019a7f5a 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -108,6 +108,22 @@ def test_constrained_bytes_too_long():
     ]
 
 
+def test_constrained_bytes_lower_enabled():
+    class Model(BaseModel):
+        v: conbytes(to_lower=True)
+
+    m = Model(v=b'ABCD')
+    assert m.v == b'abcd'
+
+
+def test_constrained_bytes_lower_disabled():
+    class Model(BaseModel):
+        v: conbytes(to_lower=False)
+
+    m = Model(v=b'ABCD')
+    assert m.v == b'ABCD'
+
+
 def test_constrained_list_good():
     class ConListModelMax(BaseModel):
         v: conlist(int) = []
@@ -442,6 +458,22 @@ def test_constrained_str_too_long():
     ]
 
 
+def test_constrained_str_lower_enabled():
+    class Model(BaseModel):
+        v: constr(to_lower=True)
+
+    m = Model(v='ABCD')
+    assert m.v == 'abcd'
+
+
+def test_constrained_str_lower_disabled():
+    class Model(BaseModel):
+        v: constr(to_lower=False)
+
+    m = Model(v='ABCD')
+    assert m.v == 'ABCD'
+
+
 def test_module_import():
     class PyObjectModel(BaseModel):
         module: PyObject = 'os.path'
@@ -1517,6 +1549,34 @@ def test_anystr_strip_whitespace_disabled():
     assert m.bytes_check == b'  456  '
 
 
+def test_anystr_lower_enabled():
+    class Model(BaseModel):
+        str_check: str
+        bytes_check: bytes
+
+        class Config:
+            anystr_lower = True
+
+    m = Model(str_check='ABCDefG', bytes_check=b'abCD1Fg')
+
+    assert m.str_check == 'abcdefg'
+    assert m.bytes_check == b'abcd1fg'
+
+
+def test_anystr_lower_disabled():
+    class Model(BaseModel):
+        str_check: str
+        bytes_check: bytes
+
+        class Config:
+            anystr_lower = False
+
+    m = Model(str_check='ABCDefG', bytes_check=b'abCD1Fg')
+
+    assert m.str_check == 'ABCDefG'
+    assert m.bytes_check == b'abCD1Fg'
+
+
 @pytest.mark.parametrize(
     'type_,value,result',
     [