Repository: samuelcolvin/pydantic (python)

Issue:
RedisDsn canot enable redis url with password and ssl schema
`redis://:password@redishost` & `rediss://redis` are valid in redis.py, but cause ValidationError in RedisDsn. 

```
In [7]: import redis

In [8]: r = redis.StrictRedis.from_url('rediss://redis')

In [9]: r = redis.StrictRedis.from_url('redis://:123@redis')

In [10]:
```

* type error list below
```
In [3]: from pydantic import BaseModel, RedisDsn

In [4]: class RedisURLModel(BaseModel):
   ...:     url: RedisDsn = None
   ...:

In [5]: c = RedisURLModel(url='redis://:myredispwd@redis')
---------------------------------------------------------------------------
ValidationError                           Traceback (most recent call last)
<ipython-input-5-23cad9c7fe6b> in <module>
----> 1 c = RedisURLModel(url='redis://:myredispwd@redis')

/usr/local/lib/python3.7/site-packages/pydantic/main.cpython-37m-x86_64-linux-gnu.so in pydantic.main.BaseModel.__init__()

ValidationError: 1 validation error for RedisURLModel
url
  URL host invalid (type=value_error.url.host)

In [6]: c = RedisURLModel(url='rediss://redis')
---------------------------------------------------------------------------
ValidationError                           Traceback (most recent call last)
<ipython-input-6-39558dddb3bc> in <module>
----> 1 c = RedisURLModel(url='rediss://redis')

/usr/local/lib/python3.7/site-packages/pydantic/main.cpython-37m-x86_64-linux-gnu.so in pydantic.main.BaseModel.__init__()

ValidationError: 1 validation error for RedisURLModel
url
  URL scheme not permitted (type=value_error.url.scheme; allowed_schemes={'redis'})
```

Pull request description:
Add `rediss` (Redis over SSL) protocol to `RedisDsn`
## Change Summary

Added `rediss` to `RedisDsn.allowed_schemes`.

## Related issue number

This PR closes #1877 

## 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/1911-TrDex.md` file added describing change
  (see [changes/README.md](https://github.com/samuelcolvin/pydantic/blob/master/changes/README.md) for details)


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b9630e829..81288f452 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,7 +72,7 @@ jobs:
       run: make test
 
     - run: coverage xml
-    - uses: codecov/codecov-action@v1.0.14
+    - uses: codecov/codecov-action@v1.0.15
       with:
         file: ./coverage.xml
         env_vars: COMPILED,DEPS,PYTHON,OS
@@ -87,7 +87,7 @@ jobs:
       run: make test
 
     - run: coverage xml
-    - uses: codecov/codecov-action@v1.0.14
+    - uses: codecov/codecov-action@v1.0.15
       with:
         file: ./coverage.xml
         env_vars: COMPILED,DEPS,PYTHON,OS
@@ -105,7 +105,7 @@ jobs:
       run: make test
 
     - run: coverage xml
-    - uses: codecov/codecov-action@v1.0.14
+    - uses: codecov/codecov-action@v1.0.15
       with:
         file: ./coverage.xml
         env_vars: COMPILED,DEPS,PYTHON,OS
@@ -143,7 +143,7 @@ jobs:
 
     - run: coverage xml
 
-    - uses: codecov/codecov-action@v1.0.14
+    - uses: codecov/codecov-action@v1.0.15
       with:
         file: ./coverage.xml
         env_vars: COMPILED,DEPS,PYTHON,OS
diff --git a/changes/1911-TrDex.md b/changes/1911-TrDex.md
new file mode 100644
index 000000000..997802283
--- /dev/null
+++ b/changes/1911-TrDex.md
@@ -0,0 +1,2 @@
+Add `rediss` (Redis over SSL) protocol to `RedisDsn`
+Allow URLs without `user` part (e.g., `rediss://:pass@localhost`)
\ No newline at end of file
diff --git a/docs/examples/types_urls.py b/docs/examples/types_urls.py
index 9874d3a6e..fb49280ff 100644
--- a/docs/examples/types_urls.py
+++ b/docs/examples/types_urls.py
@@ -7,6 +7,7 @@ class MyModel(BaseModel):
 
 m = MyModel(url='http://www.example.com')
 print(m.url)
+
 try:
     MyModel(url='ftp://invalid.url')
 except ValidationError as e:
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 70bc1d5f7..49078122f 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -3,7 +3,7 @@ flake8==3.8.4
 flake8-quotes==3.2.0
 mkdocs==1.1.2
 mkdocs-exclude==1.0.2
-mkdocs-material==6.1.6
+mkdocs-material==6.1.7
 markdown-include==0.6.0
 sqlalchemy
 orjson
diff --git a/docs/usage/types.md b/docs/usage/types.md
index 9a41fb1a7..eda606550 100644
--- a/docs/usage/types.md
+++ b/docs/usage/types.md
@@ -526,8 +526,8 @@ For URI/URL validation the following types are available:
 - `AnyHttpUrl`: schema `http` or `https`, TLD not required
 - `HttpUrl`: schema `http` or `https`, TLD required, max length 2083
 - `PostgresDsn`: schema `postgres` or `postgresql`, user info required, TLD not required
-- `RedisDsn`: schema `redis`, user info not required, tld not required (CHANGED: user info not required from
-  **v1.6** onwards)
+- `RedisDsn`: schema `redis` or `rediss`, user info not required, tld not required (CHANGED: user info
+  not required from **v1.6** onwards), user info may be passed without user part (e.g., `rediss://:pass@localhost`)
 - `stricturl`, method with the following keyword arguments:
     - `strip_whitespace: bool = True`
     - `min_length: int = 1`
diff --git a/pydantic/networks.py b/pydantic/networks.py
index 2418e5b6c..8f44eedb7 100644
--- a/pydantic/networks.py
+++ b/pydantic/networks.py
@@ -43,7 +43,6 @@ __all__ = [
     'validate_email',
 ]
 
-
 _url_regex_cache = None
 _ascii_domain_regex_cache = None
 _int_domain_regex_cache = None
@@ -54,7 +53,7 @@ def url_regex() -> Pattern[str]:
     if _url_regex_cache is None:
         _url_regex_cache = re.compile(
             r'(?:(?P<scheme>[a-z][a-z0-9+\-.]+)://)?'  # scheme https://tools.ietf.org/html/rfc3986#appendix-A
-            r'(?:(?P<user>[^\s:/]+)(?::(?P<password>[^\s/]*))?@)?'  # user info
+            r'(?:(?P<user>[^\s:/]*)(?::(?P<password>[^\s/]*))?@)?'  # user info
             r'(?:'
             r'(?P<ipv4>(?:\d{1,3}\.){3}\d{1,3})|'  # ipv4
             r'(?P<ipv6>\[[A-F0-9]*:[A-F0-9:]+\])|'  # ipv6
@@ -147,8 +146,9 @@ class AnyUrl(str):
         url = scheme + '://'
         if user:
             url += user
-            if password:
-                url += ':' + password
+        if password:
+            url += ':' + password
+        if user or password:
             url += '@'
         url += host
         if port:
@@ -183,20 +183,7 @@ class AnyUrl(str):
         assert m, 'URL regex failed unexpectedly'
 
         parts = m.groupdict()
-        scheme = parts['scheme']
-        if scheme is None:
-            raise errors.UrlSchemeError()
-
-        if cls.allowed_schemes and scheme.lower() not in cls.allowed_schemes:
-            raise errors.UrlSchemePermittedError(cls.allowed_schemes)
-
-        port = parts['port']
-        if port is not None and int(port) > 65_535:
-            raise errors.UrlPortError()
-
-        user = parts['user']
-        if cls.user_required and user is None:
-            raise errors.UrlUserInfoError()
+        parts = cls.validate_parts(parts)
 
         host, tld, host_type, rebuild = cls.validate_host(parts)
 
@@ -205,18 +192,41 @@ class AnyUrl(str):
 
         return cls(
             None if rebuild else url,
-            scheme=scheme,
-            user=user,
+            scheme=parts['scheme'],
+            user=parts['user'],
             password=parts['password'],
             host=host,
             tld=tld,
             host_type=host_type,
-            port=port,
+            port=parts['port'],
             path=parts['path'],
             query=parts['query'],
             fragment=parts['fragment'],
         )
 
+    @classmethod
+    def validate_parts(cls, parts: Dict[str, str]) -> Dict[str, str]:
+        """
+        A method used to validate parts of an URL.
+        Could be overridden to set default values for parts if missing
+        """
+        scheme = parts['scheme']
+        if scheme is None:
+            raise errors.UrlSchemeError()
+
+        if cls.allowed_schemes and scheme.lower() not in cls.allowed_schemes:
+            raise errors.UrlSchemePermittedError(cls.allowed_schemes)
+
+        port = parts['port']
+        if port is not None and int(port) > 65_535:
+            raise errors.UrlPortError()
+
+        user = parts['user']
+        if cls.user_required and user is None:
+            raise errors.UrlUserInfoError()
+
+        return parts
+
     @classmethod
     def validate_host(cls, parts: Dict[str, str]) -> Tuple[str, Optional[str], str, bool]:
         host, tld, host_type, rebuild = None, None, None, False
@@ -279,7 +289,19 @@ class PostgresDsn(AnyUrl):
 
 
 class RedisDsn(AnyUrl):
-    allowed_schemes = {'redis'}
+    allowed_schemes = {'redis', 'rediss'}
+
+    @classmethod
+    def validate_parts(cls, parts: Dict[str, str]) -> Dict[str, str]:
+        defaults = {
+            'domain': 'localhost' if not (parts['ipv4'] or parts['ipv6']) else '',
+            'port': '6379',
+            'path': '/0',
+        }
+        for key, value in defaults.items():
+            if not parts[key]:
+                parts[key] = value
+        return super().validate_parts(parts)
 
 
 def stricturl(
diff --git a/pydantic/version.py b/pydantic/version.py
index cd81ee5f2..4ba6f2dfb 100644
--- a/pydantic/version.py
+++ b/pydantic/version.py
@@ -12,7 +12,7 @@ def version_info() -> str:
     from .main import compiled
 
     optional_deps = []
-    for p in ('typing-extensions', 'email-validator', 'devtools'):
+    for p in ('devtools', 'dotenv', 'email-validator', 'typing-extensions'):
         try:
             import_module(p.replace('-', '_'))
         except ImportError:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 6cde3411b..0d1c6c434 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -46,7 +46,7 @@ class SetEnv:
             os.environ.pop(n)
 
 
-@pytest.yield_fixture
+@pytest.fixture
 def env():
     setenv = SetEnv()
 
diff --git a/tests/requirements-testing.txt b/tests/requirements-testing.txt
index 63e56b335..bb600754b 100644
--- a/tests/requirements-testing.txt
+++ b/tests/requirements-testing.txt
@@ -1,6 +1,8 @@
 coverage==5.3
+# pin importlib-metadata as upper versions need typing-extensions to work if on python < 3.8
+importlib-metadata==3.1.0;python_version<"3.8"
 mypy==0.790
-pytest==6.1.2
+pytest==6.2.1
 pytest-cov==2.10.1
-pytest-mock==3.3.1
+pytest-mock==3.4.0
 pytest-sugar==0.9.4
diff --git a/tests/test_networks.py b/tests/test_networks.py
index c69ba0a78..256fcd0ca 100644
--- a/tests/test_networks.py
+++ b/tests/test_networks.py
@@ -190,6 +190,13 @@ def test_user_no_password():
     assert url.host == 'example.org'
 
 
+def test_user_info_no_user():
+    url = validate_url('http://:password@example.org')
+    assert url.user == ''
+    assert url.password == 'password'
+    assert url.host == 'example.org'
+
+
 def test_at_in_path():
     url = validate_url('https://twitter.com/@handle')
     assert url.scheme == 'https'
@@ -321,21 +328,35 @@ def test_redis_dsns():
     class Model(BaseModel):
         a: RedisDsn
 
-    m = Model(a='redis://user:pass@localhost:5432/app')
-    assert m.a == 'redis://user:pass@localhost:5432/app'
+    m = Model(a='redis://user:pass@localhost:1234/app')
+    assert m.a == 'redis://user:pass@localhost:1234/app'
     assert m.a.user == 'user'
     assert m.a.password == 'pass'
 
+    m = Model(a='rediss://user:pass@localhost:1234/app')
+    assert m.a == 'rediss://user:pass@localhost:1234/app'
+
+    m = Model(a='rediss://:pass@localhost:1234')
+    assert m.a == 'rediss://:pass@localhost:1234/0'
+
     with pytest.raises(ValidationError) as exc_info:
         Model(a='http://example.org')
     assert exc_info.value.errors()[0]['type'] == 'value_error.url.scheme'
 
-    # password is not required for redis
-    m = Model(a='redis://localhost:5432/app')
-    assert m.a == 'redis://localhost:5432/app'
+    # Password is not required for Redis protocol
+    m = Model(a='redis://localhost:1234/app')
+    assert m.a == 'redis://localhost:1234/app'
     assert m.a.user is None
     assert m.a.password is None
 
+    # Only schema is required for Redis protocol. Otherwise it will be set to default
+    # https://www.iana.org/assignments/uri-schemes/prov/redis
+    m = Model(a='rediss://')
+    assert m.a.scheme == 'rediss'
+    assert m.a.host == 'localhost'
+    assert m.a.port == '6379'
+    assert m.a.path == '/0'
+
 
 def test_custom_schemes():
     class Model(BaseModel):