Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions python/pydantic_core/_pydantic_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ class Url(SupportsAllComparisons):
scheme: str,
username: str | None = None,
password: str | None = None,
host: str,
host: str | None = None,
port: int | None = None,
path: str | None = None,
query: str | None = None,
Expand All @@ -596,7 +596,7 @@ class Url(SupportsAllComparisons):
scheme: The scheme part of the URL.
username: The username part of the URL, or omit for no username.
password: The password part of the URL, or omit for no password.
host: The host part of the URL.
host: The host part of the URL, or omit for no host.
port: The port part of the URL, or omit for no port.
path: The path part of the URL, or omit for no path.
query: The query part of the URL, or omit for no query.
Expand Down
8 changes: 8 additions & 0 deletions python/pydantic_core/core_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3655,6 +3655,7 @@ class MyModel:

class UrlSchema(TypedDict, total=False):
type: Required[Literal['url']]
cls: Type[Any]
max_length: int
allowed_schemes: List[str]
host_required: bool # default False
Expand All @@ -3669,6 +3670,7 @@ class UrlSchema(TypedDict, total=False):

def url_schema(
*,
cls: Type[Any] | None = None,
max_length: int | None = None,
allowed_schemes: list[str] | None = None,
host_required: bool | None = None,
Expand All @@ -3693,6 +3695,7 @@ def url_schema(
```

Args:
cls: The class to use for the URL build (a subclass of `pydantic_core.Url`)
max_length: The maximum length of the URL
allowed_schemes: The allowed URL schemes
host_required: Whether the URL must have a host
Expand All @@ -3706,6 +3709,7 @@ def url_schema(
"""
return _dict_not_none(
type='url',
cls=cls,
max_length=max_length,
allowed_schemes=allowed_schemes,
host_required=host_required,
Expand All @@ -3721,6 +3725,7 @@ def url_schema(

class MultiHostUrlSchema(TypedDict, total=False):
type: Required[Literal['multi-host-url']]
cls: Type[Any]
max_length: int
allowed_schemes: List[str]
host_required: bool # default False
Expand All @@ -3735,6 +3740,7 @@ class MultiHostUrlSchema(TypedDict, total=False):

def multi_host_url_schema(
*,
cls: Type[Any] | None = None,
max_length: int | None = None,
allowed_schemes: list[str] | None = None,
host_required: bool | None = None,
Expand All @@ -3759,6 +3765,7 @@ def multi_host_url_schema(
```

Args:
cls: The class to use for the URL build (a subclass of `pydantic_core.MultiHostUrl`)
max_length: The maximum length of the URL
allowed_schemes: The allowed URL schemes
host_required: Whether the URL must have a host
Expand All @@ -3772,6 +3779,7 @@ def multi_host_url_schema(
"""
return _dict_not_none(
type='multi-host-url',
cls=cls,
max_length=max_length,
allowed_schemes=allowed_schemes,
host_required=host_required,
Expand Down
6 changes: 3 additions & 3 deletions src/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,12 @@ impl PyUrl {
}

#[classmethod]
#[pyo3(signature=(*, scheme, host, username=None, password=None, port=None, path=None, query=None, fragment=None))]
#[pyo3(signature=(*, scheme, host=None, username=None, password=None, port=None, path=None, query=None, fragment=None))]
#[allow(clippy::too_many_arguments)]
pub fn build<'py>(
cls: &Bound<'py, PyType>,
scheme: &str,
host: &str,
host: Option<&str>,
username: Option<&str>,
password: Option<&str>,
port: Option<u16>,
Expand All @@ -172,7 +172,7 @@ impl PyUrl {
let url_host = UrlHostParts {
username: username.map(Into::into),
password: password.map(Into::into),
host: Some(host.into()),
host: host.map(Into::into),
port,
};
let mut url = format!("{scheme}://{url_host}");
Expand Down
76 changes: 71 additions & 5 deletions src/validators/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::str::Chars;

use pyo3::intern;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
use pyo3::types::{PyDict, PyList, PyType};

use ahash::AHashSet;
use url::{ParseError, SyntaxViolation, Url};
Expand All @@ -15,6 +15,7 @@ use crate::errors::{ErrorType, ErrorTypeDefaults, ValError, ValResult};
use crate::input::downcast_python_input;
use crate::input::Input;
use crate::tools::SchemaDict;
use crate::url::UrlHostParts;
use crate::url::{schema_is_special, PyMultiHostUrl, PyUrl};

use super::literal::expected_repr_name;
Expand All @@ -26,6 +27,7 @@ type AllowedSchemas = Option<(AHashSet<String>, String)>;
#[derive(Debug, Clone)]
pub struct UrlValidator {
strict: bool,
cls: Option<Py<PyType>>,
max_length: Option<usize>,
allowed_schemes: AllowedSchemas,
host_required: bool,
Expand All @@ -47,6 +49,7 @@ impl BuildValidator for UrlValidator {

Ok(Self {
strict: is_strict(schema, config)?,
cls: schema.get_as(intern!(schema.py(), "cls"))?,
max_length: schema.get_as(intern!(schema.py(), "max_length"))?,
host_required: schema.get_as(intern!(schema.py(), "host_required"))?.unwrap_or(false),
default_host: schema.get_as(intern!(schema.py(), "default_host"))?,
Expand All @@ -59,7 +62,7 @@ impl BuildValidator for UrlValidator {
}
}

impl_py_gc_traverse!(UrlValidator {});
impl_py_gc_traverse!(UrlValidator { cls });

impl Validator for UrlValidator {
fn validate<'py>(
Expand Down Expand Up @@ -93,7 +96,31 @@ impl Validator for UrlValidator {
Ok(()) => {
// Lax rather than strict to preserve V2.4 semantic that str wins over url in union
state.floor_exactness(Exactness::Lax);
Ok(either_url.into_py(py))

if let Some(url_subclass) = &self.cls {
// TODO: we do an extra build for a subclass here, we should avoid this
// in v2.11 for perf reasons, but this is a worthwhile patch for now
// given that we want isinstance to work properly for subclasses of Url
let py_url = match either_url {
EitherUrl::Py(py_url) => py_url.get().clone(),
EitherUrl::Rust(rust_url) => PyUrl::new(rust_url),
};

let py_url = PyUrl::build(
url_subclass.bind(py),
py_url.scheme(),
py_url.host(),
py_url.username(),
py_url.password(),
py_url.port(),
py_url.path().filter(|path| *path != "/"),
py_url.query(),
py_url.fragment(),
)?;
Ok(py_url.into_py(py))
} else {
Ok(either_url.into_py(py))
}
}
Err(error_type) => Err(ValError::new(error_type, input)),
}
Expand Down Expand Up @@ -186,6 +213,7 @@ impl CopyFromPyUrl for EitherUrl<'_> {
#[derive(Debug, Clone)]
pub struct MultiHostUrlValidator {
strict: bool,
cls: Option<Py<PyType>>,
max_length: Option<usize>,
allowed_schemes: AllowedSchemas,
host_required: bool,
Expand Down Expand Up @@ -213,6 +241,7 @@ impl BuildValidator for MultiHostUrlValidator {
}
Ok(Self {
strict: is_strict(schema, config)?,
cls: schema.get_as(intern!(schema.py(), "cls"))?,
max_length: schema.get_as(intern!(schema.py(), "max_length"))?,
allowed_schemes,
host_required: schema.get_as(intern!(schema.py(), "host_required"))?.unwrap_or(false),
Expand All @@ -225,7 +254,7 @@ impl BuildValidator for MultiHostUrlValidator {
}
}

impl_py_gc_traverse!(MultiHostUrlValidator {});
impl_py_gc_traverse!(MultiHostUrlValidator { cls });

impl Validator for MultiHostUrlValidator {
fn validate<'py>(
Expand Down Expand Up @@ -258,7 +287,44 @@ impl Validator for MultiHostUrlValidator {
Ok(()) => {
// Lax rather than strict to preserve V2.4 semantic that str wins over url in union
state.floor_exactness(Exactness::Lax);
Ok(multi_url.into_py(py))

if let Some(url_subclass) = &self.cls {
// TODO: we do an extra build for a subclass here, we should avoid this
// in v2.11 for perf reasons, but this is a worthwhile patch for now
// given that we want isinstance to work properly for subclasses of Url
let py_url = match multi_url {
EitherMultiHostUrl::Py(py_url) => py_url.get().clone(),
EitherMultiHostUrl::Rust(rust_url) => rust_url,
};

let hosts = py_url.hosts(py).map_or(None, |hosts| {
let mut host_parts_vec = Vec::new();
for host in &hosts {
if let Ok(py_dict) = host.downcast::<PyDict>() {
if let Ok(host_parts) = UrlHostParts::extract_bound(py_dict) {
host_parts_vec.push(host_parts);
}
}
}
Some(host_parts_vec)
});

let py_url = PyMultiHostUrl::build(
url_subclass.bind(py),
py_url.scheme(),
hosts,
py_url.path().filter(|path| *path != "/"),
py_url.query(),
py_url.fragment(),
None,
None,
None,
None,
)?;
Ok(py_url.into_py(py))
} else {
Ok(multi_url.into_py(py))
}
}
Err(error_type) => Err(ValError::new(error_type, input)),
}
Expand Down
6 changes: 3 additions & 3 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ def multi_raise_py_error(v: Any) -> Any:
s2.validate_python('anything')

cause_group = exc_info.value.__cause__
assert isinstance(cause_group, BaseExceptionGroup)
assert isinstance(cause_group, BaseExceptionGroup) # noqa: F821
assert len(cause_group.exceptions) == 1

cause = cause_group.exceptions[0]
Expand All @@ -576,7 +576,7 @@ def outer_raise_py_error(v: Any) -> Any:
with pytest.raises(ValidationError) as exc_info:
s3.validate_python('anything')

assert isinstance(exc_info.value.__cause__, BaseExceptionGroup)
assert isinstance(exc_info.value.__cause__, BaseExceptionGroup) # noqa: F821
assert len(exc_info.value.__cause__.exceptions) == 1
cause = exc_info.value.__cause__.exceptions[0]
assert cause.__notes__ and cause.__notes__[-1].startswith('\nPydantic: ')
Expand All @@ -585,7 +585,7 @@ def outer_raise_py_error(v: Any) -> Any:
assert isinstance(subcause, ValidationError)

cause_group = subcause.__cause__
assert isinstance(cause_group, BaseExceptionGroup)
assert isinstance(cause_group, BaseExceptionGroup) # noqa: F821
assert len(cause_group.exceptions) == 1

cause = cause_group.exceptions[0]
Expand Down
Loading