Skip to content

Add UUID validator #584

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ url = "2.3.1"
# idna is already required by url, added here to be explicit
idna = "0.3.0"
base64 = "0.13.1"
uuid = "1.3.2"

[lib]
name = "_pydantic_core"
Expand Down
2 changes: 2 additions & 0 deletions pydantic_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
SchemaSerializer,
SchemaValidator,
Url,
Uuid,
ValidationError,
__version__,
to_json,
Expand All @@ -38,6 +39,7 @@
'SchemaValidator',
'SchemaSerializer',
'Url',
'Uuid',
'MultiHostUrl',
'ArgsKwargs',
'SchemaError',
Expand Down
12 changes: 12 additions & 0 deletions pydantic_core/_pydantic_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ __all__ = (
'SchemaSerializer',
'Url',
'MultiHostUrl',
'Uuid',
'SchemaError',
'ValidationError',
'PydanticCustomError',
Expand Down Expand Up @@ -174,6 +175,17 @@ class MultiHostUrl(SupportsAllComparisons):
def __str__(self) -> str: ...
def __repr__(self) -> str: ...

class Uuid(SupportsAllComparisons):
@property
def urn(self) -> str: ...
@property
def variant(self) -> str: ...
@property
def version(self) -> str: ...
def __init__(self, uuid: str) -> None: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__init__ should come first.

def __str__(self) -> str: ...
def __repr__(self) -> str: ...

class SchemaError(Exception):
def error_count(self) -> int: ...
def errors(self) -> 'list[ErrorDetails]': ...
Expand Down
37 changes: 37 additions & 0 deletions pydantic_core/core_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3503,6 +3503,38 @@ def multi_host_url_schema(
)


class UuidSchema(TypedDict, total=False):
type: Required[Literal['uuid']]
version: int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this should be something like Literal[3, 4, 5] - not sure exactly what's allowed, I guess that's kind of the point of using a Literal.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should at least have Literal[1, 3, 4, 5] | None so that it's possible to not enforce checking of the version. There are cases, e.g. the Nil UUID or Max UUID, where it may be desirable to not enforce a version.

Note that Python doesn't support generating UUID v2 and nor does the rust uuid crate, but perhaps that should still be included as a valid instance could still be instantiated for a v2 UUID. In addition there is an update in the works to introduce v6, v7 and v8. Not sure whether it's worth adding a comment that this might need looking into in the future? Interestingly the uuid crate does already have preliminary support for generating them, even if Python does not.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this should be something like Literal[3, 4, 5] - not sure exactly what's allowed, I guess that's kind of the point of using a Literal.

ref: str
metadata: Any
serialization: SerSchema


def uuid_schema(
*, version: int | None = None, ref: str | None = None, metadata: Any = None, serialization: SerSchema | None = None
) -> UuidSchema:
"""
Returns a schema that matches a UUID value, e.g.:

```py
from pydantic_core import SchemaValidator, core_schema

schema = core_schema.uuid_schema()
v = SchemaValidator(schema)
print(v.validate_python('12345678-1234-5678-1234-567812345678'))
#> 12345678-1234-5678-1234-567812345678
```

Args:
version: The UUID version number as per RFC 4122
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

say what it defaults to if ommitted.

ref: optional unique identifier of the schema, used to reference the schema in other places
metadata: Any other information you want to include with the schema, not used by pydantic-core
serialization: Custom serialization schema
"""
return dict_not_none(type='uuid', version=version, ref=ref, metadata=metadata, serialization=serialization)


class DefinitionsSchema(TypedDict, total=False):
type: Required[Literal['definitions']]
schema: Required[CoreSchema]
Expand Down Expand Up @@ -3613,6 +3645,7 @@ def definition_reference_schema(
JsonSchema,
UrlSchema,
MultiHostUrlSchema,
UuidSchema,
DefinitionsSchema,
DefinitionReferenceSchema,
]
Expand Down Expand Up @@ -3665,6 +3698,7 @@ def definition_reference_schema(
'json',
'url',
'multi-host-url',
'uuid',
'definitions',
'definition-ref',
]
Expand Down Expand Up @@ -3757,4 +3791,7 @@ def definition_reference_schema(
'url_syntax_violation',
'url_too_long',
'url_scheme',
'uuid_type',
'uuid_parsing',
'uuid_version_mismatch',
]
27 changes: 27 additions & 0 deletions src/errors/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,16 @@ pub enum ErrorType {
UrlScheme {
expected_schemes: String,
},
// ---------------------
// UUID errors
UuidType,
UuidParsing {
error: String,
},
UuidVersionMismatch {
version: usize,
schema_version: usize,
},
}

macro_rules! render {
Expand Down Expand Up @@ -432,6 +442,10 @@ impl ErrorType {
Self::UrlSyntaxViolation { .. } => extract_context!(Cow::Owned, UrlSyntaxViolation, ctx, error: String),
Self::UrlTooLong { .. } => extract_context!(UrlTooLong, ctx, max_length: usize),
Self::UrlScheme { .. } => extract_context!(UrlScheme, ctx, expected_schemes: String),
Self::UuidParsing { .. } => extract_context!(UuidParsing, ctx, error: String),
Self::UuidVersionMismatch { .. } => {
extract_context!(UuidVersionMismatch, ctx, version: usize, schema_version: usize)
}
_ => {
if ctx.is_some() {
py_err!(PyTypeError; "'{}' errors do not require context", value)
Expand Down Expand Up @@ -533,6 +547,9 @@ impl ErrorType {
Self::UrlSyntaxViolation {..} => "Input violated strict URL syntax rules, {error}",
Self::UrlTooLong {..} => "URL should have at most {max_length} characters",
Self::UrlScheme {..} => "URL scheme should be {expected_schemes}",
Self::UuidType => "Input should be a string",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Self::UuidType => "Input should be a string",
Self::UuidType => "UUID Input should be a string or UUID object",

or something, you'll also want a custom error message for JSON, see the function below.

Self::UuidParsing {..} => "Input should be a valid UUID, {error}",
Self::UuidVersionMismatch {..} => "UUID version {version} does not match expected version: {schema_version}",
}
}

Expand Down Expand Up @@ -632,6 +649,11 @@ impl ErrorType {
Self::UrlSyntaxViolation { error } => render!(tmpl, error),
Self::UrlTooLong { max_length } => to_string_render!(tmpl, max_length),
Self::UrlScheme { expected_schemes } => render!(tmpl, expected_schemes),
Self::UuidParsing { error } => render!(tmpl, error),
Self::UuidVersionMismatch {
version,
schema_version,
} => to_string_render!(tmpl, version, schema_version),
_ => Ok(tmpl.to_string()),
}
}
Expand Down Expand Up @@ -689,6 +711,11 @@ impl ErrorType {
Self::UrlSyntaxViolation { error } => py_dict!(py, error),
Self::UrlTooLong { max_length } => py_dict!(py, max_length),
Self::UrlScheme { expected_schemes } => py_dict!(py, expected_schemes),
Self::UuidParsing { error } => py_dict!(py, error),
Self::UuidVersionMismatch {
version,
schema_version,
} => py_dict!(py, version, schema_version),
_ => Ok(None),
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/input/input_abstract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use pyo3::prelude::*;
use pyo3::types::{PyString, PyType};

use crate::errors::{InputValue, LocItem, ValResult};
use crate::{PyMultiHostUrl, PyUrl};
use crate::{PyMultiHostUrl, PyUrl, PyUuid};

use super::datetime::{EitherDate, EitherDateTime, EitherTime, EitherTimedelta};
use super::return_enums::{EitherBytes, EitherString};
Expand Down Expand Up @@ -67,6 +67,10 @@ pub trait Input<'a>: fmt::Debug + ToPyObject {
None
}

fn input_as_uuid(&self) -> Option<PyUuid> {
None
}

fn callable(&self) -> bool {
false
}
Expand Down
6 changes: 5 additions & 1 deletion src/input/input_python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use pyo3::{ffi, intern, AsPyPointer, PyTypeInfo};

use crate::build_tools::safe_repr;
use crate::errors::{ErrorType, InputValue, LocItem, ValError, ValResult};
use crate::{ArgsKwargs, PyMultiHostUrl, PyUrl};
use crate::{ArgsKwargs, PyMultiHostUrl, PyUrl, PyUuid};

use super::datetime::{
bytes_as_date, bytes_as_datetime, bytes_as_time, bytes_as_timedelta, date_as_datetime, float_as_datetime,
Expand Down Expand Up @@ -133,6 +133,10 @@ impl<'a> Input<'a> for PyAny {
self.extract::<PyMultiHostUrl>().ok()
}

fn input_as_uuid(&self) -> Option<PyUuid> {
self.extract::<PyUuid>().ok()
}

fn callable(&self) -> bool {
self.is_callable()
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ mod lookup_key;
mod recursion_guard;
mod serializers;
mod url;
mod uuid;
mod validators;

// required for benchmarks
pub use self::url::{PyMultiHostUrl, PyUrl};
pub use self::uuid::PyUuid;
pub use argument_markers::ArgsKwargs;
pub use build_tools::SchemaError;
pub use errors::{list_all_errors, PydanticCustomError, PydanticKnownError, PydanticOmit, ValidationError};
Expand Down Expand Up @@ -55,6 +57,7 @@ fn _pydantic_core(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_class::<PydanticSerializationUnexpectedValue>()?;
m.add_class::<PyUrl>()?;
m.add_class::<PyMultiHostUrl>()?;
m.add_class::<PyUuid>()?;
m.add_class::<ArgsKwargs>()?;
m.add_class::<SchemaSerializer>()?;
m.add_function(wrap_pyfunction!(to_json, m)?)?;
Expand Down
1 change: 1 addition & 0 deletions src/serializers/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ combined_serializer! {
Model: super::type_serializers::model::ModelSerializer;
Url: super::type_serializers::url::UrlSerializer;
MultiHostUrl: super::type_serializers::url::MultiHostUrlSerializer;
Uuid: super::type_serializers::uuid::UuidSerializer;
Any: super::type_serializers::any::AnySerializer;
Format: super::type_serializers::format::FormatSerializer;
ToString: super::type_serializers::format::ToStringSerializer;
Expand Down
1 change: 1 addition & 0 deletions src/serializers/type_serializers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod tuple;
pub mod typed_dict;
pub mod union;
pub mod url;
pub mod uuid;
pub mod with_default;

pub(self) use super::computed_fields::ComputedFields;
Expand Down
80 changes: 80 additions & 0 deletions src/serializers/type_serializers/uuid.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use std::borrow::Cow;

use pyo3::prelude::*;
use pyo3::types::PyDict;

use crate::definitions::DefinitionsBuilder;
use crate::uuid::PyUuid;

use super::{
infer_json_key, infer_serialize, infer_to_python, BuildSerializer, CombinedSerializer, Extra, SerMode,
TypeSerializer,
};

#[derive(Debug, Clone)]
pub struct UuidSerializer;

impl BuildSerializer for UuidSerializer {
const EXPECTED_TYPE: &'static str = "uuid";

fn build(
_schema: &PyDict,
_config: Option<&PyDict>,
_definitions: &mut DefinitionsBuilder<CombinedSerializer>,
) -> PyResult<CombinedSerializer> {
Ok(Self {}.into())
}
}

impl TypeSerializer for UuidSerializer {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure you need this, the existing ToStringSerializer should probably surfice.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure you need this, the existing ToStringSerializer should probably surfice.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure you need this, the existing ToStringSerializer should probably suffice.

fn to_python(
&self,
value: &PyAny,
include: Option<&PyAny>,
exclude: Option<&PyAny>,
extra: &Extra,
) -> PyResult<PyObject> {
let py = value.py();
match value.extract::<PyUuid>() {
Ok(py_uuid) => match extra.mode {
SerMode::Json => Ok(py_uuid.__str__().into_py(py)),
_ => Ok(value.into_py(py)),
},
Err(_) => {
extra.warnings.on_fallback_py(self.get_name(), value, extra)?;
infer_to_python(value, include, exclude, extra)
}
}
}

fn json_key<'py>(&self, key: &'py PyAny, extra: &Extra) -> PyResult<Cow<'py, str>> {
match key.extract::<PyUuid>() {
Ok(py_uuid) => Ok(Cow::Owned(py_uuid.__str__())),
Err(_) => {
extra.warnings.on_fallback_py(self.get_name(), key, extra)?;
infer_json_key(key, extra)
}
}
}

fn serde_serialize<S: serde::ser::Serializer>(
&self,
value: &PyAny,
serializer: S,
include: Option<&PyAny>,
exclude: Option<&PyAny>,
extra: &Extra,
) -> Result<S::Ok, S::Error> {
match value.extract::<PyUuid>() {
Ok(py_uuid) => serializer.serialize_str(&py_uuid.__str__()),
Err(_) => {
extra.warnings.on_fallback_ser::<S>(self.get_name(), value, extra)?;
infer_serialize(value, serializer, include, exclude, extra)
}
}
}

fn get_name(&self) -> &str {
Self::EXPECTED_TYPE
}
}
Loading