Repository: pydantic/pydantic
Pull request: Add `UrlConstraints.__hash__`
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->
<!-- We're currently in the process of rewriting pydantic in preparation for V2, see https://docs.pydantic.dev/blog/pydantic-v2/. -->
<!-- **Note:** if you're making a pull request to fix pydantic v1.10, please make it against the `1.10.X-fixes` branch. -->

## Change Summary

<!-- Please give a short summary of the changes. -->

Adds `__hash__` implementation to `UrlConstraints`.

## Related issue number

<!-- Are there any issues opened that will be resolved by merging this change? -->
<!-- WARNING: please use "fix #123" style references so the issue is closed when this PR is merged. -->

Closes https://github.com/pydantic/pydantic/issues/5693

## 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
  (see [changes/README.md](https://github.com/samuelcolvin/pydantic/blob/master/changes/README.md) for details)
* [x] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Selected Reviewer: @hramezani

Problem statement:
`Optional[HTTPUrl]` raises `TypeError` on declaration due to unhashable type `UrlConstraints`
### Initial Checks

- [X] I confirm that I'm using Pydantic V2 installed directly from the `main` branch, or equivalent

### Description

When using a field that is a union of `None` and `HTTPUrl`, they Python typing module throws a type error due to an unhashable type.

```python
Traceback (most recent call last):
  File "/Users/mz/dev/prefect/example.py", line 5, in <module>
    class Foo(BaseModel):
  File "/Users/mz/dev/prefect/example.py", line 6, in Foo
    bar: Optional[HttpUrl] = None
         ~~~~~~~~^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 344, in inner
    return func(*args, **kwds)
           ^^^^^^^^^^^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 463, in __getitem__
    return self._getitem(self, parameters)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 685, in Optional
    return Union[arg, type(None)]
           ~~~~~^^^^^^^^^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 344, in inner
    return func(*args, **kwds)
           ^^^^^^^^^^^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 463, in __getitem__
    return self._getitem(self, parameters)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 671, in Union
    parameters = _remove_dups_flatten(parameters)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 313, in _remove_dups_flatten
    return tuple(_deduplicate(params))
                 ^^^^^^^^^^^^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 289, in _deduplicate
    all_params = set(params)
                 ^^^^^^^^^^^
  File "/Users/mz/.pyenv/versions/3.11.2/lib/python3.11/typing.py", line 2114, in __hash__
    return hash((self.__origin__, self.__metadata__))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'UrlConstraints'
```

### Example Code

```Python
from pydantic import HttpUrl, BaseModel
from typing import Optional


class Foo(BaseModel):
    bar: Optional[HttpUrl] = None
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.0a3
        pydantic-core version: 0.27.0 release build profile
                 install path: /Users/mz/.pyenv/versions/3.11.2/envs/prefect-311/lib/python3.11/site-packages/pydantic
               python version: 3.11.2 (main, Mar 15 2023, 09:11:17) [Clang 14.0.0 (clang-1400.0.29.202)]
                     platform: macOS-13.2.1-arm64-arm-64bit
     optional deps. installed: ['typing-extensions']
```
```

Hints from issue discussion:
Thanks @madkinsz for reporting.

I can confirm the problem. it can be fixed by adding a `def __hash__(self) -> int:` to `UrlConstraints`.

Would you like to prepare a patch?
@hramezani sure I'll give it a shot!

Commit message: Remove change file

Patch to review:
diff --git a/pydantic/networks.py b/pydantic/networks.py
index cda9fce19d1..c9dde43ea77 100644
--- a/pydantic/networks.py
+++ b/pydantic/networks.py
@@ -54,6 +54,18 @@ class UrlConstraints(_fields.PydanticMetadata):
     default_port: int | None = None
     default_path: str | None = None
 
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self.max_length,
+                tuple(self.allowed_schemes) if self.allowed_schemes is not None else None,
+                self.host_required,
+                self.default_host,
+                self.default_port,
+                self.default_path,
+            )
+        )
+
 
 AnyUrl = Url
 # host_required is false because all schemes are "special" so host is required by rust-url automatically
diff --git a/tests/test_networks.py b/tests/test_networks.py
index a881419b48c..ac7dcf876ef 100644
--- a/tests/test_networks.py
+++ b/tests/test_networks.py
@@ -1,3 +1,5 @@
+from typing import Union
+
 import pytest
 from pydantic_core import PydanticCustomError, Url
 from typing_extensions import Annotated
@@ -273,6 +275,13 @@ class Model(BaseModel):
     assert str(Model(v=value).v) == expected
 
 
+def test_nullable_http_url():
+    class Model(BaseModel):
+        v: Union[HttpUrl, None]
+
+    assert Model(v=None).v is None
+
+
 @pytest.mark.parametrize(
     'value,err_type,err_msg',
     [
@@ -647,6 +656,60 @@ class Model2(BaseModel):
         Model(v='ws:///foo/bar')
 
 
+@pytest.mark.parametrize(
+    'options',
+    [
+        # Ensures the hash is generated correctly when a field is null
+        {'max_length': None},
+        {'allowed_schemes': None},
+        {'host_required': None},
+        {'default_host': None},
+        {'default_port': None},
+        {'default_path': None},
+    ],
+)
+def test_url_constraints_hash_equal(options: dict):
+    defaults = {
+        'max_length': 1,
+        'allowed_schemes': ['scheme'],
+        'host_required': False,
+        'default_host': 'host',
+        'default_port': 0,
+        'default_path': 'path',
+    }
+    options = {**defaults, **options}
+    assert hash(UrlConstraints(**options)) == hash(UrlConstraints(**options))
+
+
+@pytest.mark.parametrize(
+    'changes',
+    [
+        {'max_length': 2},
+        {'allowed_schemes': ['new-scheme']},
+        {'host_required': True},
+        {'default_host': 'new-host'},
+        {'default_port': 1},
+        {'default_path': 'new-path'},
+        {'max_length': None},
+        {'allowed_schemes': None},
+        {'host_required': None},
+        {'default_host': None},
+        {'default_port': None},
+        {'default_path': None},
+    ],
+)
+def test_url_constraints_hash_inequal(changes: dict):
+    options = {
+        'max_length': 1,
+        'allowed_schemes': ['scheme'],
+        'host_required': False,
+        'default_host': 'host',
+        'default_port': 0,
+        'default_path': 'path',
+    }
+    assert hash(UrlConstraints(**options)) != hash(UrlConstraints(**{**options, **changes}))
+
+
 def test_json():
     class Model(BaseModel):
         v: HttpUrl


Code review comments:
diff_hunk: @@ -647,6 +656,60 @@ class Model2(BaseModel):
         Model(v='ws:///foo/bar')
 
 
+@pytest.mark.parametrize(
+    'options',
+    [
+        # Ensures the hash is generated correctly when a field is null
+        {'max_length': None},
+        {'allowed_schemes': None},
+        {'host_required': None},
+        {'default_host': None},
+        {'default_port': None},
+        {'default_path': None},
+    ],
+)
+def test_url_constraints_hash_equal(options: dict):
+    defaults = {
+        'max_length': 1,
+        'allowed_schemes': ['scheme'],
+        'host_required': False,
+        'default_host': 'host',
+        'default_port': 0,
+        'default_path': 'path',
+    }
+    options = {**defaults, **options}
+    assert hash(UrlConstraints(**options)) == hash(UrlConstraints(**options))
+
+
+@pytest.mark.parametrize(
+    'changes',
+    [
+        {'max_length': 2},
+        {'allowed_schemes': ['new-scheme']},
+        {'host_required': True},
+        {'default_host': 'new-host'},
+        {'default_port': 1},
+        {'default_path': 'new-path'},
+        {'max_length': None},
+        {'allowed_schemes': None},
+        {'host_required': None},
+        {'default_host': None},
+        {'default_port': None},
+        {'default_path': None},
+    ],
+)
+def test_url_constraints_hash_inequal(changes: dict):
path: tests/test_networks.py
text: @user1:
```suggestion
def test_url_constraints_hash_inequal(changes: Dict[str, Any]):
```

diff_hunk: @@ -647,6 +656,60 @@ class Model2(BaseModel):
         Model(v='ws:///foo/bar')
 
 
+@pytest.mark.parametrize(
+    'options',
+    [
+        # Ensures the hash is generated correctly when a field is null
+        {'max_length': None},
+        {'allowed_schemes': None},
+        {'host_required': None},
+        {'default_host': None},
+        {'default_port': None},
+        {'default_path': None},
+    ],
+)
+def test_url_constraints_hash_equal(options: dict):
path: tests/test_networks.py
text: @user1:
```suggestion
def test_url_constraints_hash_equal(options: Dict[str, Any]):
```