Repository: pydantic/pydantic
Pull request: Fix url constraint application
Fix https://github.com/pydantic/pydantic/issues/10914

Problem statement:
2.10: URL types issues (application of constraints)
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

Pydantic v2.10 has issues with Url types and AfterValidators (see example below)

```
tests/security/keycloak/test_config.py:None (tests/security/keycloak/test_config.py)
tests/security/keycloak/test_config.py:5: in <module>
    from yio_fastapi_utils.security.keycloak import KeycloakSettings
yio_fastapi_utils/security/keycloak/__init__.py:5: in <module>
    from yio_fastapi_utils.security.keycloak.deps import security_dependency, AccessToken
yio_fastapi_utils/security/keycloak/deps.py:12: in <module>
    from yio_fastapi_utils.security.keycloak.config import KeycloakSettings
yio_fastapi_utils/security/keycloak/config.py:17: in <module>
    class KeycloakSettings(BaseSettings):
venv/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py:226: in __new__
    complete_model_class(
venv/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py:658: in complete_model_class
    schema = cls.__get_pydantic_core_schema__(cls, handler)
venv/lib/python3.11/site-packages/pydantic/main.py:697: in __get_pydantic_core_schema__
    return handler(source)
venv/lib/python3.11/site-packages/pydantic/_internal/_schema_generation_shared.py:84: in __call__
    schema = self._handler(source_type)
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:612: in generate_schema
    schema = self._generate_schema_inner(obj)
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:881: in _generate_schema_inner
    return self._model_schema(obj)
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:693: in _model_schema
    {k: self._generate_md_field_schema(k, v, decorators) for k, v in fields.items()},
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:693: in <dictcomp>
    {k: self._generate_md_field_schema(k, v, decorators) for k, v in fields.items()},
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:1073: in _generate_md_field_schema
    common_field = self._common_field_schema(name, field_info, decorators)
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:1261: in _common_field_schema
    schema = self._apply_annotations(
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:2051: in _apply_annotations
    schema = get_inner_schema(source_type)
venv/lib/python3.11/site-packages/pydantic/_internal/_schema_generation_shared.py:84: in __call__
    schema = self._handler(source_type)
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:2126: in new_handler
    schema = metadata_get_schema(source, get_inner_schema)
venv/lib/python3.11/site-packages/pydantic/functional_validators.py:73: in __get_pydantic_core_schema__
    schema = handler(source_type)
venv/lib/python3.11/site-packages/pydantic/_internal/_schema_generation_shared.py:84: in __call__
    schema = self._handler(source_type)
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:2127: in new_handler
    schema = self._apply_single_annotation(schema, annotation)
venv/lib/python3.11/site-packages/pydantic/_internal/_generate_schema.py:2093: in _apply_single_annotation
    maybe_updated_schema = _known_annotated_metadata.apply_known_metadata(metadata, schema.copy())
venv/lib/python3.11/site-packages/pydantic/_internal/_known_annotated_metadata.py:285: in apply_known_metadata
    raise RuntimeError(f"Unable to apply constraint '{constraint}' to schema of type '{schema_type}'")
E   RuntimeError: Unable to apply constraint 'allowed_schemes' to schema of type 'function-wrap'
```

### Example Code

```Python
def remove_trailing_slash(url: str) -> str:
    """
    Remove trailing slash from url.

    :param url: url to be cleaned
    :return: cleaned url
    """
    return url[:-1] if url.endswith("/") else url


FQHttpsUrl = Annotated[AnyUrl, UrlConstraints(allowed_schemes=['https'])]

RealmName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, to_lower=True)]

ServerUrl = Annotated[FQHttpsUrl, AfterValidator(lambda value: FQHttpsUrl(remove_trailing_slash(str(value))))]
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.10.0
        pydantic-core version: 2.27.0
          pydantic-core build: profile=release pgo=false
                 install path: /Users/dh/Development/yio/yio/yio-fastapi-utils/venv/lib/python3.11/site-packages/pydantic
               python version: 3.11.10 (main, Sep  7 2024, 01:03:31) [Clang 15.0.0 (clang-1500.3.9.4)]
                     platform: macOS-15.0-arm64-arm-64bit
             related packages: pydantic-settings-2.6.1 pydantic-extra-types-2.10.0 mypy-1.8.0 fastapi-0.115.5 typing_extensions-4.12.2
                       commit: unknown
```

Hints from issue discussion:
I'm pretty sure now that the issue comes down to this construct:

```python
from typing import Annotated

from pydantic import AnyUrl, UrlConstraints, StringConstraints, AfterValidator, TypeAdapter
from pydantic.experimental.pipeline import validate_as

# not working anymore
FQHttpsUrlBroken = Annotated[AnyUrl, UrlConstraints(allowed_schemes=['https'])]

TypeAdapter(FQHttpsUrlBroken)
```


@dhofstetter,

Thanks for reporting this! We're taking a closer look now!

Commit message: add tests

Patch to review:
diff --git a/pydantic/networks.py b/pydantic/networks.py
index 33799578c06..3cca666b9ef 100644
--- a/pydantic/networks.py
+++ b/pydantic/networks.py
@@ -17,7 +17,7 @@
 
 from pydantic.errors import PydanticUserError
 
-from ._internal import _fields, _repr, _schema_generation_shared
+from ._internal import _repr, _schema_generation_shared
 from ._migration import getattr_migration
 from .annotated_handlers import GetCoreSchemaHandler
 from .json_schema import JsonSchemaValue
@@ -62,7 +62,7 @@
 
 
 @_dataclasses.dataclass
-class UrlConstraints(_fields.PydanticMetadata):
+class UrlConstraints:
     """Url constraints.
 
     Attributes:
@@ -96,7 +96,31 @@ def __hash__(self) -> int:
     @property
     def defined_constraints(self) -> dict[str, Any]:
         """Fetch a key / value mapping of constraints to values that are not None. Used for core schema updates."""
-        return {field.name: getattr(self, field.name) for field in fields(self)}
+        return {field.name: value for field in fields(self) if (value := getattr(self, field.name)) is not None}
+
+    def __get_pydantic_core_schema__(self, source: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
+        schema = handler(source)
+
+        if schema['type'] == 'function-wrap':
+            # for function-wrap schemas, url constraints is applied to the inner schema
+            inner = schema['schema']
+
+            if annotated_type := inner['type'] not in ('url', 'multi-host-url'):
+                raise PydanticUserError(
+                    f"'UrlConstraints' cannot annotate '{annotated_type}'.", code='invalid-annotated-type'
+                )
+            for constraint_key, constraint_value in self.defined_constraints.items():
+                inner[constraint_key] = constraint_value
+            schema['schema'] = inner
+        else:
+            if annotated_type := schema['type'] not in ('url', 'multi-host-url'):
+                raise PydanticUserError(
+                    f"'UrlConstraints' cannot annotate '{annotated_type}'.", code='invalid-annotated-type'
+                )
+            for constraint_key, constraint_value in self.defined_constraints.items():
+                schema[constraint_key] = constraint_value
+
+        return schema
 
 
 class _BaseUrl:
@@ -254,33 +278,21 @@ def build(
     def __get_pydantic_core_schema__(
         cls, source: type[_BaseUrl], handler: GetCoreSchemaHandler
     ) -> core_schema.CoreSchema:
-        if issubclass(cls, source):
-
-            def wrap_val(value, handler):
-                if isinstance(value, source):
-                    return value
-                if isinstance(value, _BaseUrl):
-                    value = str(value)
-                core_url = handler(value)
-                instance = source.__new__(source)
-                instance._url = core_url
-                return instance
-
-            return core_schema.no_info_wrap_validator_function(
-                wrap_val,
-                schema=core_schema.url_schema(**cls._constraints.defined_constraints),
-                serialization=core_schema.to_string_ser_schema(),
-            )
-        else:
-            schema = handler(source)
-            # TODO: this logic is used in types.py as well in the _check_annotated_type function, should we move that to somewhere more central?
-            if annotated_type := schema['type'] not in ('url', 'multi-host-url'):
-                raise PydanticUserError(
-                    f"'{cls.__name__}' cannot annotate '{annotated_type}'.", code='invalid-annotated-type'
-                )
-            for constraint_key, constraint_value in cls._constraints.defined_constraints.items():
-                schema[constraint_key] = constraint_value
-            return schema
+        def wrap_val(v, h):
+            if isinstance(v, source):
+                return v
+            if isinstance(v, _BaseUrl):
+                v = str(v)
+            core_url = h(v)
+            instance = source.__new__(source)
+            instance._url = core_url
+            return instance
+
+        return core_schema.no_info_wrap_validator_function(
+            wrap_val,
+            schema=core_schema.url_schema(**cls._constraints.defined_constraints),
+            serialization=core_schema.to_string_ser_schema(),
+        )
 
 
 class _BaseMultiHostUrl:
@@ -416,33 +428,21 @@ def build(
     def __get_pydantic_core_schema__(
         cls, source: type[_BaseMultiHostUrl], handler: GetCoreSchemaHandler
     ) -> core_schema.CoreSchema:
-        if issubclass(cls, source):
-
-            def wrap_val(value, handler):
-                if isinstance(value, source):
-                    return value
-                if isinstance(value, _BaseMultiHostUrl):
-                    value = str(value)
-                core_url = handler(value)
-                instance = source.__new__(source)
-                instance._url = core_url
-                return instance
-
-            return core_schema.no_info_wrap_validator_function(
-                wrap_val,
-                schema=core_schema.multi_host_url_schema(**cls._constraints.defined_constraints),
-                serialization=core_schema.to_string_ser_schema(),
-            )
-        else:
-            schema = handler(source)
-            # TODO: this logic is used in types.py as well in the _check_annotated_type function, should we move that to somewhere more central?
-            if annotated_type := schema['type'] not in ('url', 'multi-host-url'):
-                raise PydanticUserError(
-                    f"'{cls.__name__}' cannot annotate '{annotated_type}'.", code='invalid-annotated-type'
-                )
-            for constraint_key, constraint_value in cls._constraints.defined_constraints.items():
-                schema[constraint_key] = constraint_value
-            return schema
+        def wrap_val(v, h):
+            if isinstance(v, source):
+                return v
+            if isinstance(v, _BaseMultiHostUrl):
+                v = str(v)
+            core_url = h(v)
+            instance = source.__new__(source)
+            instance._url = core_url
+            return instance
+
+        return core_schema.no_info_wrap_validator_function(
+            wrap_val,
+            schema=core_schema.multi_host_url_schema(**cls._constraints.defined_constraints),
+            serialization=core_schema.to_string_ser_schema(),
+        )
 
 
 @lru_cache
diff --git a/tests/test_networks.py b/tests/test_networks.py
index 7935ae74253..6623e25fff2 100644
--- a/tests/test_networks.py
+++ b/tests/test_networks.py
@@ -6,6 +6,7 @@
 from typing_extensions import Annotated
 
 from pydantic import (
+    AfterValidator,
     AmqpDsn,
     AnyHttpUrl,
     AnyUrl,
@@ -1072,3 +1073,26 @@ def test_url_subclasses_any_url() -> None:
 
     url = TypeAdapter(AnyUrl).validate_python(http_url)
     assert url is http_url
+
+
+def test_custom_constraints() -> None:
+    HttpUrl = Annotated[AnyUrl, UrlConstraints(allowed_schemes=['http', 'https'])]
+    ta = TypeAdapter(HttpUrl)
+    assert ta.validate_python('https://example.com')
+
+    with pytest.raises(ValidationError):
+        ta.validate_python('ftp://example.com')
+
+
+def test_after_validator() -> None:
+    def remove_trailing_slash(url: AnyUrl) -> str:
+        """Custom url -> str transformer that removes trailing slash."""
+        return str(url._url).rstrip('/')
+
+    HttpUrl = Annotated[
+        AnyUrl,
+        UrlConstraints(allowed_schemes=['http', 'https']),
+        AfterValidator(lambda url: remove_trailing_slash(url)),
+    ]
+    ta = TypeAdapter(HttpUrl)
+    assert ta.validate_python('https://example.com/') == 'https://example.com'


Code review comments:
diff_hunk: @@ -96,7 +96,31 @@ def __hash__(self) -> int:
     @property
     def defined_constraints(self) -> dict[str, Any]:
         """Fetch a key / value mapping of constraints to values that are not None. Used for core schema updates."""
-        return {field.name: getattr(self, field.name) for field in fields(self)}
+        return {field.name: value for field in fields(self) if (value := getattr(self, field.name)) is not None}
+
+    def __get_pydantic_core_schema__(self, source: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
+        schema = handler(source)
+
+        if schema['type'] == 'function-wrap':
+            # for function-wrap schemas, url constraints is applied to the inner schema
+            inner = schema['schema']
+
+            if annotated_type := inner['type'] not in ('url', 'multi-host-url'):
+                raise PydanticUserError(
path: pydantic/networks.py
text: @user1:
I think you could simplify here:

```suggestion
        if schema['type'] == 'function-wrap':
            # for function-wrap schemas, url constraints is applied to the inner schema
            inner = schema['schema']

            if annotated_type := inner['type'] in ('url', 'multi-host-url'):
                schema = inner
```

@author:
Hmm, I don't think this works

@user1:
Why not? It seems to me that 112-113 is repeated with 120-121 with the only difference being `inner` vs `schema`.

@author:
Because we still want to return `schema`, just with `inner` reassigned after modification. We still need the `function-wrap`

diff_hunk: @@ -96,7 +96,31 @@ def __hash__(self) -> int:
     @property
     def defined_constraints(self) -> dict[str, Any]:
         """Fetch a key / value mapping of constraints to values that are not None. Used for core schema updates."""
-        return {field.name: getattr(self, field.name) for field in fields(self)}
+        return {field.name: value for field in fields(self) if (value := getattr(self, field.name)) is not None}
+
+    def __get_pydantic_core_schema__(self, source: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
+        schema = handler(source)
+
+        if schema['type'] == 'function-wrap':
+            # for function-wrap schemas, url constraints is applied to the inner schema
+            inner = schema['schema']
+
+            if annotated_type := inner['type'] not in ('url', 'multi-host-url'):
+                raise PydanticUserError(
+                    f"'UrlConstraints' cannot annotate '{annotated_type}'.", code='invalid-annotated-type'
+                )
+            for constraint_key, constraint_value in self.defined_constraints.items():
+                inner[constraint_key] = constraint_value
+            schema['schema'] = inner
path: pydantic/networks.py
text: @user1:
This seems like a noop (inner is mutated but not recreated).

```suggestion
```