Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Self schema (pydantic#131)
Browse files Browse the repository at this point in the history
* generating self schema, fix pydantic#127

* allow generating self schema with older python

* schema generating

* remove pydantic dependency from generating schema

* removing unused schema recursion checks

* use build.rs, run not eval

* fix to makefile

* fixing schema generation

* custom discriminator and fixing tests

* forbid extra

* fix build

* fix build for 3.8

* trying to fix ci...

* fix benchmarks

* coverage

* coverage
samuelcolvin authored Jul 18, 2022
1 parent 515ff10 commit 5c33ba0
Showing 34 changed files with 449 additions and 254 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -170,6 +170,7 @@ jobs:
with:
python-version: '3.10'

- run: pip install 'black>=22.3.0,<23' typing_extensions
- run: make rust-benchmark

build-wasm-emscripten:
@@ -208,7 +209,7 @@ jobs:
run: cargo update -p pydantic-core
if: "startsWith(github.ref, 'refs/tags/')"

- run: pip install 'maturin>=0.13,<0.14'
- run: pip install 'maturin>=0.13,<0.14' 'black>=22.3.0,<23' typing_extensions

- name: build wheels
run: make build-wasm
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -28,3 +28,4 @@ docs/_build/
node_modules/
package-lock.json
/pytest-speed/
/src/self_schema.py
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := all
isort = isort pydantic_core tests
black = black pydantic_core tests
isort = isort pydantic_core tests generate_self_schema.py
black = black pydantic_core tests generate_self_schema.py

.PHONY: install
install:
@@ -132,6 +132,7 @@ clean:
rm -f `find . -type f -name '*.py[co]' `
rm -f `find . -type f -name '*~' `
rm -f `find . -type f -name '.*~' `
rm -rf src/self_schema.py
rm -rf .cache
rm -rf flame
rm -rf htmlcov
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* remove int from bool parsing - covered by float check below
8 changes: 4 additions & 4 deletions benches/main.rs
Original file line number Diff line number Diff line change
@@ -228,7 +228,7 @@ fn as_str(i: u8) -> String {
fn dict_json(bench: &mut Bencher) {
let gil = Python::acquire_gil();
let py = gil.python();
let validator = build_schema_validator(py, "{'type': 'dict', 'keys': 'str', 'values': 'int'}");
let validator = build_schema_validator(py, "{'type': 'dict', 'keys_schema': 'str', 'values_schema': 'int'}");

let code = format!(
"{{{}}}",
@@ -245,7 +245,7 @@ fn dict_json(bench: &mut Bencher) {
fn dict_python(bench: &mut Bencher) {
let gil = Python::acquire_gil();
let py = gil.python();
let validator = build_schema_validator(py, "{'type': 'dict', 'keys': 'str', 'values': 'int'}");
let validator = build_schema_validator(py, "{'type': 'dict', 'keys_schema': 'str', 'values_schema': 'int'}");

let code = format!(
"{{{}}}",
@@ -318,7 +318,7 @@ fn typed_dict_json(bench: &mut Bencher) {
py,
r#"{
'type': 'typed-dict',
'extra': 'ignore',
'extra_behavior': 'ignore',
'fields': {
'a': {'schema': 'int'},
'b': {'schema': 'int'},
@@ -347,7 +347,7 @@ fn typed_dict_python(bench: &mut Bencher) {
py,
r#"{
'type': 'typed-dict',
'extra': 'ignore',
'extra_behavior': 'ignore',
'fields': {
'a': {'schema': 'int'},
'b': {'schema': 'int'},
18 changes: 18 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
use std::process::Command;
use std::str::from_utf8;

fn generate_self_schema() {
let output = Command::new("python")
.arg("generate_self_schema.py")
.output()
.expect("failed to execute process");

if !output.status.success() {
let stdout = from_utf8(&output.stdout).unwrap();
let stderr = from_utf8(&output.stderr).unwrap();
eprint!("{}{}", stdout, stderr);
panic!("generate_self_schema.py failed with {}", output.status);
}
}

fn main() {
pyo3_build_config::use_pyo3_cfgs();
if let Some(true) = version_check::supports_feature("no_coverage") {
println!("cargo:rustc-cfg=has_no_coverage");
}
generate_self_schema()
}
176 changes: 176 additions & 0 deletions generate_self_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""
This script generates the schema for the schema - e.g.
a definition of what inputs can be provided to `SchemaValidator()`.
The schema is generated from `pydantic_core/_types.py`.
"""
import importlib.util
import re
from collections.abc import Callable
from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Any, Dict, ForwardRef, List, Type, Union

from black import Mode, TargetVersion, format_file_contents
from typing_extensions import get_args, is_typeddict

try:
from typing import get_origin
except ImportError:

def get_origin(t):
return getattr(t, '__origin__', None)


THIS_DIR = Path(__file__).parent
SAVE_PATH = THIS_DIR / 'src' / 'self_schema.py'

# can't import _types.py directly as pydantic-core might not be installed
core_types_spec = importlib.util.spec_from_file_location('_typing', str(THIS_DIR / 'pydantic_core' / '_types.py'))
core_types = importlib.util.module_from_spec(core_types_spec)
core_types_spec.loader.exec_module(core_types)

# the validator for referencing schema (Schema is used recursively, so has to use a reference)
schema_ref_validator = {'type': 'recursive-ref', 'schema_ref': 'root-schema'}


def get_schema(obj):
if isinstance(obj, str):
return obj
elif obj in (datetime, timedelta, date, time, bool, int, float, str):
return obj.__name__
elif is_typeddict(obj):
return type_dict_schema(obj)
elif obj == Any:
return 'any'
elif obj == type:
# todo
return 'any'

origin = get_origin(obj)
assert origin is not None, f'origin cannot be None, obj={obj}'
if origin is Union:
return union_schema(obj)
elif obj is Callable or origin is Callable:
return 'callable'
elif origin is core_types.Literal:
expected = all_literal_values(obj)
assert expected, f'literal "expected" cannot be empty, obj={obj}'
return {'type': 'literal', 'expected': expected}
elif issubclass(origin, List):
return {'type': 'list', 'items_schema': get_schema(obj.__args__[0])}
elif issubclass(origin, Dict):
return {
'type': 'dict',
'keys_schema': get_schema(obj.__args__[0]),
'values_schema': get_schema(obj.__args__[1]),
}
elif issubclass(origin, Type):
# can't really use 'is-instance' since this is used for the class_ parameter of
# 'is-instance' validators
return 'any'
else:
# debug(obj)
raise TypeError(f'Unknown type: {obj!r}')


def type_dict_schema(typed_dict):
required_keys = getattr(typed_dict, '__required_keys__', set())
fields = {}

for field_name, field_type in typed_dict.__annotations__.items():
required = field_name in required_keys
schema = None
if type(field_type) == ForwardRef:
fr_arg = field_type.__forward_arg__
fr_arg, matched = re.subn(r'NotRequired\[(.+)]', r'\1', fr_arg)
if matched:
required = False

fr_arg, matched = re.subn(r'Required\[(.+)]', r'\1', fr_arg)
if matched:
required = True

if 'Schema' == fr_arg or re.search('[^a-zA-Z]Schema', fr_arg):
if fr_arg == 'Schema':
schema = schema_ref_validator
elif fr_arg == 'List[Schema]':
schema = {'type': 'list', 'items_schema': schema_ref_validator}
elif fr_arg == 'Dict[str, Schema]':
schema = {'type': 'dict', 'keys_schema': 'str', 'values_schema': schema_ref_validator}
else:
raise ValueError(f'Unknown Schema forward ref: {fr_arg}')
else:
field_type = eval_forward_ref(field_type)

if schema is None:
if get_origin(field_type) == core_types.Required:
required = True
field_type = field_type.__args__[0]
if get_origin(field_type) == core_types.NotRequired:
required = False
field_type = field_type.__args__[0]

schema = get_schema(field_type)

fields[field_name] = {'schema': schema, 'required': required}

return {'type': 'typed-dict', 'description': typed_dict.__name__, 'fields': fields, 'extra_behavior': 'forbid'}


def union_schema(union_type):
return {'type': 'union', 'choices': [get_schema(arg) for arg in union_type.__args__]}


def all_literal_values(type_):
if get_origin(type_) is core_types.Literal:
values = get_args(type_)
return [x for value in values for x in all_literal_values(value)]
else:
return [type_]


def eval_forward_ref(type_):
try:
return type_._evaluate(core_types.__dict__, None, set())
except TypeError:
# for older python (3.7 at least)
return type_._evaluate(core_types.__dict__, None)


def main():
schema_union = core_types.Schema
assert get_origin(schema_union) is Union, 'expected pydantic_core._types.Schema to be a union'

schema = {
'type': 'tagged-union',
'ref': 'root-schema',
'discriminator': 'self-schema-discriminator',
'choices': {'plain-string': get_schema(schema_union.__args__[0])},
}
for s in schema_union.__args__[1:]:
type_ = s.__annotations__['type']
m = re.search(r"Literal\['(.+?)']", type_.__forward_arg__)
assert m, f'Unknown schema type: {type_}'
key = m.group(1)
value = get_schema(s)
if key == 'function' and value['fields']['mode']['schema']['expected'] == ['plain']:
key = 'function-plain'
schema['choices'][key] = value

python_code = (
f'# this file is auto-generated by generate_self_schema.py, DO NOT edit manually\nself_schema = {schema}\n'
)
mode = Mode(
line_length=120,
string_normalization=False,
magic_trailing_comma=False,
target_versions={TargetVersion.PY37, TargetVersion.PY38, TargetVersion.PY39, TargetVersion.PY310},
)
python_code = format_file_contents(python_code, fast=False, mode=mode)
SAVE_PATH.write_text(python_code)
print(f'Self schema definition written to {SAVE_PATH}')


if __name__ == '__main__':
main()
31 changes: 16 additions & 15 deletions pydantic_core/_types.py
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
else:
from typing import NotRequired, Required

if sys.version_info < (3, 8):
if sys.version_info < (3, 9):
from typing_extensions import Literal, TypedDict
else:
from typing import Literal, TypedDict
@@ -70,7 +70,7 @@ class FunctionSchema(TypedDict):
type: Literal['function']
mode: Literal['before', 'after', 'wrap']
function: Callable[..., Any]
schema: Schema
schema: NotRequired[Schema]
ref: NotRequired[str]


@@ -111,6 +111,7 @@ class ModelClassSchema(TypedDict):
type: Literal['model-class']
class_type: type
schema: TypedDictSchema
strict: NotRequired[bool]
ref: NotRequired[str]
config: NotRequired[Config]

@@ -273,30 +274,30 @@ class CallableSchema(TypedDict):


# pydantic allows types to be defined via a simple string instead of dict with just `type`, e.g.
# 'int' is equivalent to {'type': 'int'}
# 'int' is equivalent to {'type': 'int'}, this only applies to schema types which do not have other required fields
BareType = Literal[
'any',
'bool',
'none',
'str',
'bytes',
'dict',
'float',
'function',
'int',
'bool',
'float',
'dict',
'list',
'model',
'model-class',
'none',
'nullable',
'recursive-container',
'recursive-reference',
'set',
'str',
# tuple-fix-len cannot be created without more typing information
'frozenset',
'tuple-var-len',
'union',
'date',
'time',
'datetime',
'timedelta',
'callable',
]

# generate_self_schema.py is hard coded to convert this Union[BareType, Union[...rest]] where the second union is tagged
# so `BareType` MUST come first
Schema = Union[
BareType,
AnySchema,
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["maturin>=0.13,<0.14"]
requires = ["maturin>=0.13,<0.14", "black>=22.3.0,<23", "typing_extensions"]
build-backend = "maturin"

[project]
5 changes: 2 additions & 3 deletions src/build_tools.rs
Original file line number Diff line number Diff line change
@@ -96,12 +96,11 @@ impl SchemaError {
PyErr::new::<SchemaError, A>(args)
}

pub fn from_val_error(py: Python, prefix: &str, error: ValError) -> PyErr {
pub fn from_val_error(py: Python, error: ValError) -> PyErr {
match error {
ValError::LineErrors(line_errors) => {
let join = if line_errors.len() == 1 { ":" } else { ":\n" };
let details = pretty_line_errors(py, line_errors);
SchemaError::new_err(format!("{}{}{}", prefix, join, details))
SchemaError::new_err(format!("Invalid Schema:\n{}", details))
}
ValError::InternalErr(py_err) => py_err,
}
5 changes: 2 additions & 3 deletions src/lookup_key.rs
Original file line number Diff line number Diff line change
@@ -241,14 +241,13 @@ impl PathItem {
if let Ok(str_key) = obj.extract::<String>() {
let py_str_key = py_string!(py, &str_key);
Ok(Self::S(str_key, py_str_key))
} else if let Ok(int_key) = obj.extract::<usize>() {
} else {
let int_key = obj.extract::<usize>()?;
if index == 0 {
py_error!(PyTypeError; "The first item in an alias path must be a string")
} else {
Ok(Self::I(int_key))
}
} else {
py_error!(PyTypeError; "Alias path items must be with a string or int")
}
}

14 changes: 4 additions & 10 deletions src/validators/date.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use pyo3::prelude::*;
use pyo3::types::PyDict;
use pyo3::types::{PyDate, PyDict};
use speedate::{Date, Time};

use crate::build_tools::{is_strict, SchemaDict, SchemaError};
use crate::build_tools::{is_strict, SchemaDict};
use crate::errors::{ErrorKind, ValError, ValResult};
use crate::input::{EitherDate, Input};
use crate::recursion_guard::RecursionGuard;
@@ -149,14 +149,8 @@ fn date_from_datetime<'data>(
}

fn convert_pydate(schema: &PyDict, field: &str) -> PyResult<Option<Date>> {
match schema.get_as::<&PyAny>(field)? {
Some(obj) => {
let prefix = format!(r#"Invalid "{}" constraint for date"#, field);
let date = obj
.validate_date(false)
.map_err(|e| SchemaError::from_val_error(obj.py(), &prefix, e))?;
Ok(Some(date.as_raw()?))
}
match schema.get_as::<&PyDate>(field)? {
Some(date) => Ok(Some(EitherDate::Py(date).as_raw()?)),
None => Ok(None),
}
}
16 changes: 5 additions & 11 deletions src/validators/datetime.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use pyo3::prelude::*;
use pyo3::types::PyDict;
use pyo3::types::{PyDateTime, PyDict};
use speedate::DateTime;

use crate::build_tools::{is_strict, SchemaDict, SchemaError};
use crate::build_tools::{is_strict, SchemaDict};
use crate::errors::{py_err_string, ErrorKind, ValError, ValResult};
use crate::input::Input;
use crate::input::{EitherDateTime, Input};
use crate::recursion_guard::RecursionGuard;

use super::{BuildContext, BuildValidator, CombinedValidator, Extra, Validator};
@@ -101,14 +101,8 @@ impl Validator for DateTimeValidator {
}

fn py_datetime_as_datetime(schema: &PyDict, field: &str) -> PyResult<Option<DateTime>> {
match schema.get_as::<&PyAny>(field)? {
Some(obj) => {
let prefix = format!(r#"Invalid "{}" constraint for datetime"#, field);
let date = obj
.validate_datetime(false)
.map_err(|e| SchemaError::from_val_error(obj.py(), &prefix, e))?;
Ok(Some(date.as_raw()?))
}
match schema.get_as::<&PyDateTime>(field)? {
Some(dt) => Ok(Some(EitherDateTime::Py(dt).as_raw()?)),
None => Ok(None),
}
}
17 changes: 2 additions & 15 deletions src/validators/function.rs
Original file line number Diff line number Diff line change
@@ -49,7 +49,7 @@ macro_rules! impl_build {
let name = format!("{}[{}]", $name, validator.get_name());
Ok(Self {
validator: Box::new(validator),
func: get_function(schema)?,
func: schema.get_as_req::<&PyAny>("function")?.into_py(schema.py()),
config: match config {
Some(c) => c.into(),
None => schema.py().None(),
@@ -158,7 +158,7 @@ impl FunctionPlainValidator {
py_error!("Plain functions should not include a sub-schema")
} else {
Ok(Self {
func: get_function(schema)?,
func: schema.get_as_req::<&PyAny>("function")?.into_py(schema.py()),
config: match config {
Some(c) => c.into(),
None => schema.py().None(),
@@ -269,19 +269,6 @@ impl ValidatorCallable {
}
}

fn get_function(schema: &PyDict) -> PyResult<PyObject> {
match schema.get_item("function") {
Some(obj) => {
if obj.is_callable() {
Ok(obj.into())
} else {
py_error!("function must be callable")
}
}
None => py_error!(r#""function" key is required"#),
}
}

fn convert_err<'a>(py: Python<'a>, err: PyErr, input: &'a impl Input<'a>) -> ValError<'a> {
// Only ValueError and AssertionError are considered as validation errors,
// TypeError is now considered as a runtime error to catch errors in function signatures
77 changes: 43 additions & 34 deletions src/validators/mod.rs
Original file line number Diff line number Diff line change
@@ -2,7 +2,8 @@ use std::fmt::Debug;

use enum_dispatch::enum_dispatch;

use pyo3::exceptions::{PyRecursionError, PyTypeError};
use pyo3::exceptions::PyTypeError;
use pyo3::once_cell::GILOnceCell;
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyByteArray, PyBytes, PyDict, PyString};

@@ -49,16 +50,22 @@ pub struct SchemaValidator {
impl SchemaValidator {
#[new]
pub fn py_new(py: Python, schema: &PyAny, config: Option<&PyDict>) -> PyResult<Self> {
let self_schema = Self::get_self_schema(py);

let schema_obj = self_schema
.validator
.validate(
py,
schema,
&Extra::default(),
&self_schema.slots,
&mut RecursionGuard::default(),
)
.map_err(|e| SchemaError::from_val_error(py, e))?;
let schema = schema_obj.as_ref(py);

let mut build_context = BuildContext::default();
let mut validator = match build_validator(schema, config, &mut build_context) {
Ok((v, _)) => v,
Err(err) => {
return Err(match err.is_instance_of::<SchemaError>(py) {
true => err,
false => SchemaError::new_err(format!("Schema build error:\n {}", err)),
});
}
};
let (mut validator, _) = build_validator(schema, config, &mut build_context)?;
build_context.complete_validators()?;
validator.complete(&build_context)?;
let slots = build_context.into_slots()?;
@@ -162,8 +169,33 @@ impl SchemaValidator {
}
}

static SCHEMA_DEFINITION: GILOnceCell<SchemaValidator> = GILOnceCell::new();

impl SchemaValidator {
pub fn prepare_validation_err(&self, py: Python, error: ValError) -> PyErr {
fn get_self_schema(py: Python) -> &Self {
SCHEMA_DEFINITION.get_or_init(py, || Self::build_self_schema(py).unwrap())
}

fn build_self_schema(py: Python) -> PyResult<Self> {
let code = include_str!("../self_schema.py");
let locals = PyDict::new(py);
py.run(code, None, Some(locals))?;
let self_schema: &PyDict = locals.get_as_req("self_schema")?;

let mut build_context = BuildContext::default();
let validator = match build_validator(self_schema, None, &mut build_context) {
Ok((v, _)) => v,
Err(err) => return Err(SchemaError::new_err(format!("Error building self-schema:\n {}", err))),
};
Ok(Self {
validator,
slots: build_context.into_slots()?,
schema: py.None(),
title: "Self Schema".into_py(py),
})
}

fn prepare_validation_err(&self, py: Python, error: ValError) -> PyErr {
ValidationError::from_val_error(py, self.title.clone_ref(py), error)
}
}
@@ -197,8 +229,6 @@ fn build_single_validator<'a, T: BuildValidator>(
config: Option<&'a PyDict>,
build_context: &mut BuildContext,
) -> PyResult<(CombinedValidator, &'a PyDict)> {
build_context.incr_check_depth()?;

let val: CombinedValidator = if let Some(schema_ref) = schema_dict.get_as::<String>("ref")? {
let slot_id = build_context.prepare_slot(schema_ref)?;
let inner_val = T::build(schema_dict, config, build_context)
@@ -211,7 +241,6 @@ fn build_single_validator<'a, T: BuildValidator>(
.map_err(|err| SchemaError::new_err(format!("Error building \"{}\" validator:\n {}", val_type, err)))?
};

build_context.decr_depth();
Ok((val, schema_dict))
}

@@ -430,15 +459,8 @@ pub trait Validator: Send + Sync + Clone + Debug {
#[derive(Default, Clone)]
pub struct BuildContext {
slots: Vec<(String, Option<CombinedValidator>)>,
depth: usize,
}

#[cfg(not(PyPy))]
const MAX_DEPTH: usize = 100;

#[cfg(PyPy)]
const MAX_DEPTH: usize = 50;

impl BuildContext {
pub fn prepare_slot(&mut self, slot_ref: String) -> PyResult<usize> {
let id = self.slots.len();
@@ -456,19 +478,6 @@ impl BuildContext {
}
}

pub fn incr_check_depth(&mut self) -> PyResult<()> {
self.depth += 1;
if self.depth > MAX_DEPTH {
py_error!(PyRecursionError; "Recursive detected, depth exceeded max allowed value of {}", MAX_DEPTH)
} else {
Ok(())
}
}

pub fn decr_depth(&mut self) {
self.depth -= 1;
}

pub fn find_slot_id(&self, val_ref: &str) -> PyResult<usize> {
let is_match = |(slot_ref, _): &(String, Option<CombinedValidator>)| slot_ref == val_ref;
match self.slots.iter().position(is_match) {
8 changes: 2 additions & 6 deletions src/validators/model_class.rs
Original file line number Diff line number Diff line change
@@ -37,12 +37,8 @@ impl BuildValidator for ModelClassValidator {
let class: &PyType = schema.get_as_req("class_type")?;
let sub_schema: &PyAny = schema.get_as_req("schema")?;
let (validator, td_schema) = build_validator(sub_schema, config, build_context)?;
let schema_type: String = td_schema.get_as_req("type")?;
if &schema_type != "typed-dict" {
return py_error!("model-class expected a 'typed-dict' schema, got '{}'", schema_type);
}
let return_fields_set = td_schema.get_as("return_fields_set")?.unwrap_or(false);
if !return_fields_set {

if !td_schema.get_as("return_fields_set")?.unwrap_or(false) {
return py_error!(r#"model-class inner schema must have "return_fields_set" set to True"#);
}

16 changes: 5 additions & 11 deletions src/validators/time.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use pyo3::prelude::*;
use pyo3::types::PyDict;
use pyo3::types::{PyDict, PyTime};
use speedate::Time;

use crate::build_tools::{is_strict, SchemaDict, SchemaError};
use crate::build_tools::{is_strict, SchemaDict};
use crate::errors::{ErrorKind, ValError, ValResult};
use crate::input::Input;
use crate::input::{EitherTime, Input};
use crate::recursion_guard::RecursionGuard;

use super::{BuildContext, BuildValidator, CombinedValidator, Extra, Validator};
@@ -94,14 +94,8 @@ impl Validator for TimeValidator {
}

fn convert_pytime(schema: &PyDict, field: &str) -> PyResult<Option<Time>> {
match schema.get_as::<&PyAny>(field)? {
Some(obj) => {
let prefix = format!(r#"Invalid "{}" constraint for time"#, field);
let date = obj
.validate_time(false)
.map_err(|e| SchemaError::from_val_error(obj.py(), &prefix, e))?;
Ok(Some(date.as_raw()?))
}
match schema.get_as::<&PyTime>(field)? {
Some(date) => Ok(Some(EitherTime::Py(date).as_raw()?)),
None => Ok(None),
}
}
15 changes: 4 additions & 11 deletions src/validators/timedelta.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
use pyo3::prelude::*;
use pyo3::types::PyDict;
use pyo3::types::{PyDelta, PyDict};
use speedate::Duration;

use crate::build_tools::{is_strict, SchemaDict};
use crate::errors::{ErrorKind, ValError, ValResult};
use crate::input::Input;
use crate::input::{EitherTimedelta, Input};
use crate::recursion_guard::RecursionGuard;
use crate::SchemaError;

use super::{BuildContext, BuildValidator, CombinedValidator, Extra, Validator};

@@ -94,14 +93,8 @@ impl Validator for TimeDeltaValidator {
}

fn py_timedelta_as_timedelta(schema: &PyDict, field: &str) -> PyResult<Option<Duration>> {
match schema.get_as::<&PyAny>(field)? {
Some(obj) => {
let prefix = format!(r#"Invalid "{}" constraint for timedelta"#, field);
let timedelta = obj
.validate_timedelta(false)
.map_err(|e| SchemaError::from_val_error(obj.py(), &prefix, e))?;
Ok(Some(timedelta.as_raw()))
}
match schema.get_as::<&PyDelta>(field)? {
Some(timedelta) => Ok(Some(EitherTimedelta::Py(timedelta).as_raw())),
None => Ok(None),
}
}
57 changes: 48 additions & 9 deletions src/validators/union.rs
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ use ahash::AHashMap;

use crate::build_tools::{is_strict, schema_or_config, SchemaDict};
use crate::errors::{ErrorKind, ValError, ValLineError, ValResult};
use crate::input::{GenericMapping, Input};
use crate::input::{EitherString, GenericMapping, Input};
use crate::lookup_key::LookupKey;
use crate::recursion_guard::RecursionGuard;

@@ -117,24 +117,33 @@ impl Validator for UnionValidator {

#[derive(Debug, Clone)]
enum Discriminator {
/// use `LookupKey` to find the tag, same as we do to find values in typed_dict aliases
LookupKey(LookupKey),
/// call a function to find the tag to use
Function(PyObject),
/// Custom discriminator specifically for the root `Schema` union in self-schema
SelfSchema,
}

impl Discriminator {
fn new(py: Python, raw: &PyAny) -> PyResult<Self> {
if raw.is_callable() {
Ok(Self::Function(raw.to_object(py)))
} else {
let lookup_key = LookupKey::from_py(py, raw, None)?;
Ok(Self::LookupKey(lookup_key))
return Ok(Self::Function(raw.to_object(py)));
} else if let Ok(str) = raw.strict_str() {
if str.as_cow().as_ref() == "self-schema-discriminator" {
return Ok(Self::SelfSchema);
}
}

let lookup_key = LookupKey::from_py(py, raw, None)?;
Ok(Self::LookupKey(lookup_key))
}

fn to_string_py(&self, py: Python) -> PyResult<String> {
match self {
Self::Function(f) => Ok(format!("{}()", f.getattr(py, "__name__")?)),
Self::LookupKey(lookup_key) => Ok(lookup_key.to_string()),
Self::SelfSchema => Ok("self-schema".to_string()),
}
}
}
@@ -161,6 +170,7 @@ impl BuildValidator for TaggedUnionValidator {
let py = schema.py();
let discriminator = Discriminator::new(py, schema.get_as_req("discriminator")?)?;
let discriminator_repr = discriminator.to_string_py(py)?;
dbg!(&discriminator_repr);

let mut choices = AHashMap::new();
let mut first = true;
@@ -234,12 +244,41 @@ impl Validator for TaggedUnionValidator {
self.find_call_validator(py, tag.as_cow(), input, extra, slots, recursion_guard)
}
Discriminator::Function(ref func) => {
let result = func.call1(py, (input.to_object(py),))?;
if result.is_none(py) {
let tag = func.call1(py, (input.to_object(py),))?;
if tag.is_none(py) {
Err(self.tag_not_found(input))
} else {
let result_str: &PyString = result.cast_as(py)?;
self.find_call_validator(py, result_str.to_string_lossy(), input, extra, slots, recursion_guard)
let tag: &PyString = tag.cast_as(py)?;
self.find_call_validator(py, tag.to_string_lossy(), input, extra, slots, recursion_guard)
}
}
Discriminator::SelfSchema => {
if input.strict_str().is_ok() {
// input is a string, must be a bare type
self.find_call_validator(py, Cow::Borrowed("plain-string"), input, extra, slots, recursion_guard)
} else {
let dict = input.strict_dict()?;
let mut tag = match dict {
GenericMapping::PyDict(dict) => match dict.get_item("type") {
Some(t) => t.strict_str()?,
None => return Err(self.tag_not_found(input)),
},
_ => unreachable!(),
};
// custom logic to distinguish between different function schemas
if tag.as_cow().as_ref() == "function" {
let mode = match dict {
GenericMapping::PyDict(dict) => match dict.get_item("mode") {
Some(m) => m.strict_str()?,
None => return Err(self.tag_not_found(input)),
},
_ => unreachable!(),
};
if mode.as_cow().as_ref() == "plain" {
tag = EitherString::Cow(Cow::Borrowed("function-plain"))
}
}
self.find_call_validator(py, tag.as_cow(), input, extra, slots, recursion_guard)
}
}
}
30 changes: 13 additions & 17 deletions tests/benchmarks/test_micro_benchmarks.py
Original file line number Diff line number Diff line change
@@ -48,8 +48,8 @@ class CoreModel:
'fields': {
'name': {'schema': {'type': 'str'}},
'age': {'schema': {'type': 'int'}},
'friends': {'schema': {'type': 'list', 'items': {'type': 'int'}}},
'settings': {'schema': {'type': 'dict', 'keys': {'type': 'str'}, 'values': {'type': 'float'}}},
'friends': {'schema': {'type': 'list', 'items_schema': 'int'}},
'settings': {'schema': {'type': 'dict', 'keys_schema': 'str', 'values_schema': 'float'}},
},
},
}
@@ -227,11 +227,7 @@ class PydanticRoot(BaseModel):
@pytest.mark.benchmark(group='List[TypedDict]')
def test_list_of_dict_models_core(benchmark):
v = SchemaValidator(
{
'type': 'list',
'name': 'Branch',
'items': {'type': 'typed-dict', 'fields': {'width': {'schema': {'type': 'int'}}}},
}
{'type': 'list', 'items_schema': {'type': 'typed-dict', 'fields': {'width': {'schema': {'type': 'int'}}}}}
)

data = [{'width': i} for i in range(100)]
@@ -255,7 +251,7 @@ def t():

@pytest.mark.benchmark(group='List[int]')
def test_list_of_ints_core_py(benchmark):
v = SchemaValidator({'type': 'list', 'items': {'type': 'int'}})
v = SchemaValidator({'type': 'list', 'items_schema': {'type': 'int'}})

@benchmark
def t():
@@ -279,7 +275,7 @@ def t():

@pytest.mark.benchmark(group='List[int] JSON')
def test_list_of_ints_core_json(benchmark):
v = SchemaValidator({'type': 'list', 'items': {'type': 'int'}})
v = SchemaValidator({'type': 'list', 'items_schema': {'type': 'int'}})

json_data = [json.dumps(d) for d in list_of_ints_data]

@@ -316,7 +312,7 @@ def t():

@pytest.mark.benchmark(group='Set[int]')
def test_set_of_ints_core(benchmark):
v = SchemaValidator({'type': 'set', 'items': {'type': 'int'}})
v = SchemaValidator({'type': 'set', 'items_schema': {'type': 'int'}})

@benchmark
def t():
@@ -340,7 +336,7 @@ def t():

@pytest.mark.benchmark(group='Set[int] JSON')
def test_set_of_ints_core_json(benchmark):
v = SchemaValidator({'type': 'set', 'items': {'type': 'int'}})
v = SchemaValidator({'type': 'set', 'items_schema': {'type': 'int'}})

json_data = [json.dumps(list(d)) for d in set_of_ints_data]

@@ -364,7 +360,7 @@ class PydanticModel(BaseModel):

@pytest.mark.benchmark(group='FrozenSet[int]')
def test_frozenset_of_ints_core(benchmark):
v = SchemaValidator({'type': 'frozenset', 'items': {'type': 'int'}})
v = SchemaValidator({'type': 'frozenset', 'items_schema': {'type': 'int'}})

benchmark(v.validate_python, frozenset_of_ints)

@@ -386,7 +382,7 @@ def t():

@pytest.mark.benchmark(group='Dict[str, int]')
def test_dict_of_ints_core(benchmark):
v = SchemaValidator({'type': 'dict', 'keys': 'str', 'values': 'int'})
v = SchemaValidator({'type': 'dict', 'keys_schema': 'str', 'values_schema': 'int'})

@benchmark
def t():
@@ -420,7 +416,7 @@ def t():

@pytest.mark.benchmark(group='Dict[str, int] JSON')
def test_dict_of_ints_core_json(benchmark):
v = SchemaValidator({'type': 'dict', 'keys': 'str', 'values': 'int'})
v = SchemaValidator({'type': 'dict', 'keys_schema': 'str', 'values_schema': 'int'})

json_data = [json.dumps(d) for d in dict_of_ints_data]

@@ -447,7 +443,7 @@ class PydanticModel(BaseModel):

@pytest.mark.benchmark(group='List[DictSimpleMode]')
def test_many_models_core_dict(benchmark):
model_schema = {'type': 'list', 'items': {'type': 'typed-dict', 'fields': {'age': {'schema': 'int'}}}}
model_schema = {'type': 'list', 'items_schema': {'type': 'typed-dict', 'fields': {'age': {'schema': 'int'}}}}
v = SchemaValidator(model_schema)
benchmark(v.validate_python, many_models_data)

@@ -460,7 +456,7 @@ class MyCoreModel:
v = SchemaValidator(
{
'type': 'list',
'items': {
'items_schema': {
'type': 'model-class',
'class_type': MyCoreModel,
'schema': {'type': 'typed-dict', 'return_fields_set': True, 'fields': {'age': {'schema': 'int'}}},
@@ -484,7 +480,7 @@ class PydanticModel(BaseModel):

@pytest.mark.benchmark(group='List[Nullable[int]]')
def test_list_of_nullable_core(benchmark):
v = SchemaValidator({'type': 'list', 'items': {'type': 'nullable', 'schema': 'int'}})
v = SchemaValidator({'type': 'list', 'items_schema': {'type': 'nullable', 'schema': 'int'}})

benchmark(v.validate_python, list_of_nullable_data)

45 changes: 27 additions & 18 deletions tests/test_build.py
Original file line number Diff line number Diff line change
@@ -6,34 +6,24 @@


def test_build_error_type():
with pytest.raises(SchemaError, match='Unknown schema type: "foobar"'):
with pytest.raises(SchemaError, match="Input tag 'foobar' found using self-schema does not match any of the"):
SchemaValidator({'type': 'foobar', 'title': 'TestModel'})


def test_build_error_internal():
msg = (
'Error building "str" validator:\n'
' TypeError: \'str\' object cannot be interpreted as an integer' # noqa Q003
)
with pytest.raises(SchemaError, match=msg):
with pytest.raises(SchemaError, match='Value must be a valid integer, unable to parse string as an integer'):
SchemaValidator({'type': 'str', 'min_length': 'xxx', 'title': 'TestModel'})


def test_build_error_deep():
with pytest.raises(SchemaError) as exc_info:
with pytest.raises(SchemaError, match='Value must be a valid integer, unable to parse string as an integer'):
SchemaValidator(
{
'title': 'MyTestModel',
'type': 'typed-dict',
'fields': {'age': {'schema': {'type': 'int', 'ge': 'not-int'}}},
}
)
assert str(exc_info.value) == (
'Error building "typed-dict" validator:\n'
' SchemaError: Field "age":\n'
' SchemaError: Error building "int" validator:\n'
" TypeError: 'str' object cannot be interpreted as an integer"
)


def test_schema_as_string():
@@ -45,7 +35,7 @@ def test_schema_wrong_type():
with pytest.raises(SchemaError) as exc_info:
SchemaValidator(1)
assert exc_info.value.args[0] == (
"Schema build error:\n TypeError: 'int' object cannot be converted to 'PyString'"
'Invalid Schema:\n Value must be a valid dictionary [kind=dict_type, input_value=1, input_type=int]'
)


@@ -61,10 +51,8 @@ def test_pickle(pickle_protocol: int) -> None:

def test_schema_recursive_error():
schema = {'type': 'union', 'choices': []}
schema['choices'].append(schema)
with pytest.raises(
SchemaError, match=r'RecursionError: Recursive detected, depth exceeded max allowed value of \d+'
):
schema['choices'].append({'type': 'nullable', 'schema': schema})
with pytest.raises(SchemaError, match='Recursion error - cyclic reference detected'):
SchemaValidator(schema)


@@ -75,3 +63,24 @@ def test_not_schema_recursive_error():
}
v = SchemaValidator(schema)
assert repr(v).count('TypedDictField') == 101


def test_no_type():
with pytest.raises(SchemaError, match='Unable to extract tag using discriminator self-schema'):
SchemaValidator({})


def test_wrong_type():
with pytest.raises(SchemaError, match="Input tag 'unknown' found using self-schema does not match any of the"):
SchemaValidator({'type': 'unknown'})


def test_function_no_mode():
with pytest.raises(SchemaError, match='Unable to extract tag using discriminator self-schema'):
SchemaValidator({'type': 'function'})


def test_try_self_schema_discriminator():
"""Trying to use self-schema when it shouldn't be used"""
v = SchemaValidator({'type': 'tagged-union', 'choices': {'int': 'int'}, 'discriminator': 'self-schema'})
assert 'discriminator: LookupKey' in repr(v)
2 changes: 1 addition & 1 deletion tests/test_json.py
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
[('false', False), ('true', True), ('0', False), ('1', True), ('"yes"', True), ('"no"', False)],
)
def test_bool(input_value, output_value):
v = SchemaValidator({'type': 'bool', 'title': 'TestModel'})
v = SchemaValidator({'type': 'bool'})
assert v.validate_json(input_value) == output_value


2 changes: 1 addition & 1 deletion tests/validators/test_date.py
Original file line number Diff line number Diff line change
@@ -176,7 +176,7 @@ def test_date_kwargs(kwargs: Dict[str, Any], input_value, expected):


def test_invalid_constraint():
with pytest.raises(SchemaError, match='Invalid "gt" constraint for date: Value must be a valid date in the forma'):
with pytest.raises(SchemaError, match='date -> gt\n Value must be a valid date or datetime'):
SchemaValidator({'type': 'date', 'gt': 'foobar'})


2 changes: 1 addition & 1 deletion tests/validators/test_datetime.py
Original file line number Diff line number Diff line change
@@ -257,5 +257,5 @@ def test_union():


def test_invalid_constraint():
with pytest.raises(SchemaError, match='Invalid "gt" constraint for datetime: Value must be a valid datetime'):
with pytest.raises(SchemaError, match='datetime -> gt\n Value must be a valid datetime'):
SchemaValidator({'type': 'datetime', 'gt': 'foobar'})
2 changes: 1 addition & 1 deletion tests/validators/test_frozenset.py
Original file line number Diff line number Diff line change
@@ -182,7 +182,7 @@ def test_union_frozenset_int_frozenset_str(input_value, expected):


def test_frozenset_as_dict_keys(py_and_json: PyAndJson):
v = py_and_json({'type': 'dict', 'keys_schema': {'type': 'frozenset'}, 'value': 'int'})
v = py_and_json({'type': 'dict', 'keys_schema': {'type': 'frozenset'}, 'values_schema': 'int'})
with pytest.raises(ValidationError, match=re.escape('Value must be a valid frozenset')):
v.validate_test({'foo': 'bar'})

58 changes: 22 additions & 36 deletions tests/validators/test_function.py
Original file line number Diff line number Diff line change
@@ -14,9 +14,7 @@ def test_function_before():
def f(input_value, **kwargs):
return input_value + ' Changed'

v = SchemaValidator(
{'title': 'Test', 'type': 'function', 'mode': 'before', 'function': f, 'schema': {'type': 'str'}}
)
v = SchemaValidator({'type': 'function', 'mode': 'before', 'function': f, 'schema': {'type': 'str'}})

assert v.validate_python('input value') == 'input value Changed'

@@ -25,9 +23,7 @@ def test_function_before_raise():
def f(input_value, **kwargs):
raise ValueError('foobar')

v = SchemaValidator(
{'title': 'Test', 'type': 'function', 'mode': 'before', 'function': f, 'schema': {'type': 'str'}}
)
v = SchemaValidator({'type': 'function', 'mode': 'before', 'function': f, 'schema': {'type': 'str'}})

with pytest.raises(ValidationError) as exc_info:
assert v.validate_python('input value') == 'input value Changed'
@@ -48,13 +44,7 @@ def f(input_value, **kwargs):
return input_value + 'x'

v = SchemaValidator(
{
'title': 'Test',
'type': 'function',
'mode': 'before',
'function': f,
'schema': {'type': 'str', 'max_length': 5},
}
{'type': 'function', 'mode': 'before', 'function': f, 'schema': {'type': 'str', 'max_length': 5}}
)

assert v.validate_python('1234') == '1234x'
@@ -79,7 +69,6 @@ def f(input_value, **kwargs):

v = SchemaValidator(
{
'title': 'Test',
'type': 'function',
'mode': 'before',
'function': f,
@@ -105,16 +94,17 @@ def test_function_wrap():
def f(input_value, *, validator, **kwargs):
return validator(input_value) + ' Changed'

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'wrap', 'function': f, 'schema': 'str'})
v = SchemaValidator({'type': 'function', 'mode': 'wrap', 'function': f, 'schema': 'str'})

assert v.validate_python('input value') == 'input value Changed'


def test_function_wrap_repr():
def f(input_value, *, validator, **kwargs):
assert repr(validator) == str(validator)
return plain_repr(validator)

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'wrap', 'function': f, 'schema': 'str'})
v = SchemaValidator({'type': 'function', 'mode': 'wrap', 'function': f, 'schema': 'str'})

assert v.validate_python('input value') == 'ValidatorCallable(Str(StrValidator{strict:false}))'

@@ -123,24 +113,24 @@ def test_function_wrap_str():
def f(input_value, *, validator, **kwargs):
return plain_repr(validator)

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'wrap', 'function': f, 'schema': 'str'})
v = SchemaValidator({'type': 'function', 'mode': 'wrap', 'function': f, 'schema': 'str'})

assert v.validate_python('input value') == 'ValidatorCallable(Str(StrValidator{strict:false}))'


def test_function_wrap_not_callable():
with pytest.raises(SchemaError, match='SchemaError: function must be callable'):
SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'wrap', 'function': [], 'schema': 'str'})
with pytest.raises(SchemaError, match='function -> function\n Input must be callable'):
SchemaValidator({'type': 'function', 'mode': 'wrap', 'function': [], 'schema': 'str'})

with pytest.raises(SchemaError, match='SchemaError: "function" key is required'):
SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'wrap', 'schema': 'str'})
with pytest.raises(SchemaError, match='function -> function\n Field required'):
SchemaValidator({'type': 'function', 'mode': 'wrap', 'schema': 'str'})


def test_wrap_error():
def f(input_value, *, validator, **kwargs):
return validator(input_value) * 2

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'wrap', 'function': f, 'schema': 'int'})
v = SchemaValidator({'type': 'function', 'mode': 'wrap', 'function': f, 'schema': 'int'})

assert v.validate_python('42') == 84
with pytest.raises(ValidationError) as exc_info:
@@ -156,8 +146,8 @@ def f(input_value, *, validator, **kwargs):


def test_wrong_mode():
with pytest.raises(SchemaError, match='SchemaError: Unexpected function mode "foobar"'):
SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'foobar', 'schema': 'str'})
with pytest.raises(SchemaError, match='function -> mode\n Value must be one of'):
SchemaValidator({'type': 'function', 'mode': 'foobar', 'schema': 'str'})


def test_function_after_data():
@@ -170,7 +160,6 @@ def f(input_value, **kwargs):

v = SchemaValidator(
{
'title': 'Test',
'type': 'typed-dict',
'fields': {
'field_a': {'schema': {'type': 'int'}},
@@ -193,7 +182,6 @@ def f(input_value, **kwargs):

v = SchemaValidator(
{
'title': 'Test',
'type': 'typed-dict',
'fields': {
'test_field': {
@@ -216,9 +204,7 @@ def f(input_value, **kwargs):
f_kwargs = deepcopy(kwargs)
return input_value + ' Changed'

v = SchemaValidator(
{'type': 'function', 'mode': 'after', 'function': f, 'schema': {'type': 'str'}, 'title': 'Test'}
)
v = SchemaValidator({'type': 'function', 'mode': 'after', 'function': f, 'schema': {'type': 'str'}})

assert v.validate_python(123) == '123 Changed'
assert f_kwargs == {'data': None, 'config': None}
@@ -228,14 +214,14 @@ def test_function_plain():
def f(input_value, **kwargs):
return input_value * 2

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'plain', 'function': f})
v = SchemaValidator({'type': 'function', 'mode': 'plain', 'function': f})

assert v.validate_python(1) == 2
assert v.validate_python('x') == 'xx'


def test_plain_schema():
with pytest.raises(SchemaError, match='Plain functions should not include a sub-schema'):
def test_plain_with_schema():
with pytest.raises(SchemaError, match='function-plain -> schema\n Extra values are not permitted'):
SchemaValidator({'type': 'function', 'mode': 'plain', 'function': lambda x: x, 'schema': 'str'})


@@ -309,7 +295,7 @@ def test_raise_assertion_error():
def f(input_value, **kwargs):
raise AssertionError('foobar')

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'before', 'function': f, 'schema': 'str'})
v = SchemaValidator({'type': 'function', 'mode': 'before', 'function': f, 'schema': 'str'})

with pytest.raises(ValidationError) as exc_info:
v.validate_python('input value')
@@ -329,7 +315,7 @@ def test_raise_assertion_error_plain():
def f(input_value, **kwargs):
raise AssertionError

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'before', 'function': f, 'schema': 'str'})
v = SchemaValidator({'type': 'function', 'mode': 'before', 'function': f, 'schema': 'str'})

with pytest.raises(ValidationError) as exc_info:
v.validate_python('input value')
@@ -354,7 +340,7 @@ def __str__(self):
def f(input_value, **kwargs):
raise MyError()

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'before', 'function': f, 'schema': 'str'})
v = SchemaValidator({'type': 'function', 'mode': 'before', 'function': f, 'schema': 'str'})

with pytest.raises(RuntimeError, match='internal error'):
v.validate_python('input value')
@@ -364,7 +350,7 @@ def test_raise_type_error():
def f(input_value, **kwargs):
raise TypeError('foobar')

v = SchemaValidator({'title': 'Test', 'type': 'function', 'mode': 'before', 'function': f, 'schema': 'str'})
v = SchemaValidator({'type': 'function', 'mode': 'before', 'function': f, 'schema': 'str'})

with pytest.raises(TypeError, match='^foobar$'):
v.validate_python('input value')
25 changes: 22 additions & 3 deletions tests/validators/test_model_class.py
Original file line number Diff line number Diff line change
@@ -76,7 +76,6 @@ def f(input_value, *, validator, **kwargs):

v = SchemaValidator(
{
'title': 'Test',
'type': 'function',
'mode': 'wrap',
'function': f,
@@ -100,13 +99,33 @@ def test_model_class_bad_model():
class MyModel:
pass

with pytest.raises(SchemaError, match=re.escape("model-class expected a 'typed-dict' schema, got 'str'")):
with pytest.raises(SchemaError, match="model-class -> schema -> type\n Value must be 'typed-dict'"):
SchemaValidator({'type': 'model-class', 'class_type': MyModel, 'schema': {'type': 'str'}})


def test_model_class_not_type():
with pytest.raises(SchemaError, match=re.escape("TypeError: 'int' object cannot be converted to 'PyType'")):
SchemaValidator({'type': 'model-class', 'class_type': 123})
SchemaValidator(
{
'type': 'model-class',
'class_type': 123,
'schema': {'type': 'typed-dict', 'return_fields_set': True, 'fields': {'field_a': {'schema': 'str'}}},
}
)


def test_not_return_fields_set():
class MyModel:
pass

with pytest.raises(SchemaError, match='model-class inner schema must have "return_fields_set" set to True'):
SchemaValidator(
{
'type': 'model-class',
'class_type': MyModel,
'schema': {'type': 'typed-dict', 'fields': {'field_a': {'schema': 'str'}}},
}
)


def test_model_class_instance_direct():
2 changes: 1 addition & 1 deletion tests/validators/test_set.py
Original file line number Diff line number Diff line change
@@ -184,6 +184,6 @@ def test_union_set_int_set_str(input_value, expected):


def test_set_as_dict_keys(py_and_json: PyAndJson):
v = py_and_json({'type': 'dict', 'keys_schema': {'type': 'set'}, 'value': 'int'})
v = py_and_json({'type': 'dict', 'keys_schema': {'type': 'set'}, 'values_schema': 'int'})
with pytest.raises(ValidationError, match=re.escape('Value must be a valid set')):
v.validate_test({'foo': 'bar'})
11 changes: 6 additions & 5 deletions tests/validators/test_string.py
Original file line number Diff line number Diff line change
@@ -105,11 +105,12 @@ def test_str_constrained_config():


def test_invalid_regex():
with pytest.raises(SchemaError) as exc_info:
SchemaValidator({'type': 'str', 'pattern': 123})
assert exc_info.value.args[0] == (
'Error building "str" validator:\n TypeError: \'int\' object cannot be converted to \'PyString\''
)
# TODO uncomment and fix once #150 is done
# with pytest.raises(SchemaError) as exc_info:
# SchemaValidator({'type': 'str', 'pattern': 123})
# assert exc_info.value.args[0] == (
# 'Error building "str" validator:\n TypeError: \'int\' object cannot be converted to \'PyString\''
# )
with pytest.raises(SchemaError) as exc_info:
SchemaValidator({'type': 'str', 'pattern': '(abc'})
assert exc_info.value.args[0] == (
2 changes: 1 addition & 1 deletion tests/validators/test_time.py
Original file line number Diff line number Diff line change
@@ -168,7 +168,7 @@ def test_time_bound_ctx():


def test_invalid_constraint():
with pytest.raises(SchemaError, match='Invalid "gt" constraint for time: Value must be in a valid time format'):
with pytest.raises(SchemaError, match='Value must be in a valid time format'):
SchemaValidator({'type': 'time', 'gt': 'foobar'})


10 changes: 2 additions & 8 deletions tests/validators/test_timedelta.py
Original file line number Diff line number Diff line change
@@ -158,18 +158,12 @@ def test_timedelta_kwargs_strict():


def test_invalid_constraint():
with pytest.raises(SchemaError, match='Invalid "gt" constraint for timedelta: Value must be a valid timedelta'):
with pytest.raises(SchemaError, match='timedelta -> gt\n Value must be a valid timedelta, invalid digit in'):
SchemaValidator({'type': 'timedelta', 'gt': 'foobar'})

with pytest.raises(SchemaError, match='Invalid "le" constraint for timedelta: Value must be a valid timedelta'):
with pytest.raises(SchemaError, match='timedelta -> le\n Value must be a valid timedelta, invalid digit in'):
SchemaValidator({'type': 'timedelta', 'le': 'foobar'})

with pytest.raises(SchemaError, match='Invalid "lt" constraint for timedelta: Value must be a valid timedelta'):
SchemaValidator({'type': 'timedelta', 'lt': 'foobar'})

with pytest.raises(SchemaError, match='Invalid "ge" constraint for timedelta: Value must be a valid timedelta'):
SchemaValidator({'type': 'timedelta', 'ge': 'foobar'})


def test_dict_py():
v = SchemaValidator({'type': 'dict', 'keys_schema': 'timedelta', 'values_schema': 'int'})
13 changes: 0 additions & 13 deletions tests/validators/test_tuple.py
Original file line number Diff line number Diff line change
@@ -292,19 +292,6 @@ def test_union_tuple_fix_len(input_value, expected):
assert v.validate_python(input_value) == expected


@pytest.mark.parametrize(
'tuple_variant,items,expected',
[
('tuple-var-len', {'type': 'mint'}, Err('Error building "tuple-var-len" validator')),
('tuple-fix-len', [{'type': 'mint'}], Err('Error building "tuple-fix-len" validator')),
],
)
def test_error_building_tuple_with_wrong_items(tuple_variant: TupleVariant, items, expected):

with pytest.raises(SchemaError, match=re.escape(expected.message)):
SchemaValidator({'type': tuple_variant, 'items_schema': items})


def test_tuple_fix_error():
v = SchemaValidator({'type': 'tuple-fix-len', 'items_schema': ['int', 'str']})
with pytest.raises(ValidationError) as exc_info:
18 changes: 7 additions & 11 deletions tests/validators/test_typed_dict.py
Original file line number Diff line number Diff line change
@@ -350,7 +350,7 @@ def test_json_error():


def test_missing_schema_key():
with pytest.raises(SchemaError, match='SchemaError: Field "x":\n KeyError: \'schema\''):
with pytest.raises(SchemaError, match='typed-dict -> fields -> x -> schema\n Field required'):
SchemaValidator({'type': 'typed-dict', 'fields': {'x': {'type': 'str'}}})


@@ -572,11 +572,11 @@ def test_paths_allow_by_name(py_and_json: PyAndJson, input_value):
@pytest.mark.parametrize(
'alias_schema,error',
[
({'alias': ['foo', ['bar']]}, 'TypeError: Alias path items must be with a string or int'),
({'alias': ['foo', ['bar']]}, 'Value must be a valid string'),
({'alias': []}, 'Lookup paths must have at least one element'),
({'alias': [[]]}, 'Each alias path must have at least one element'),
({'alias': [123]}, "TypeError: 'int' object cannot be converted to 'PyList'"),
({'alias': [[[]]]}, 'TypeError: Alias path items must be with a string or int'),
({'alias': [[[]]]}, 'Value must be a valid string'),
({'alias': [[1, 'foo']]}, 'TypeError: The first item in an alias path must be a string'),
],
ids=repr,
@@ -974,7 +974,7 @@ def test_alias_extra(py_and_json: PyAndJson):
v = py_and_json(
{
'type': 'typed-dict',
'typed_dict_extra_behavior': 'allow',
'extra_behavior': 'allow',
'fields': {'field_a': {'alias': [['FieldA'], ['foo', 2]], 'schema': 'int'}},
}
)
@@ -999,7 +999,7 @@ def test_alias_extra_from_attributes():
v = SchemaValidator(
{
'type': 'typed-dict',
'typed_dict_extra_behavior': 'allow',
'extra_behavior': 'allow',
'from_attributes': True,
'fields': {'field_a': {'alias': [['FieldA'], ['foo', 2]], 'schema': 'int'}},
}
@@ -1014,7 +1014,7 @@ def test_alias_extra_by_name(py_and_json: PyAndJson):
v = py_and_json(
{
'type': 'typed-dict',
'typed_dict_extra_behavior': 'allow',
'extra_behavior': 'allow',
'from_attributes': True,
'populate_by_name': True,
'fields': {'field_a': {'alias': 'FieldA', 'schema': 'int'}},
@@ -1028,11 +1028,7 @@ def test_alias_extra_by_name(py_and_json: PyAndJson):

def test_alias_extra_forbid(py_and_json: PyAndJson):
v = py_and_json(
{
'type': 'typed-dict',
'typed_dict_extra_behavior': 'forbid',
'fields': {'field_a': {'alias': 'FieldA', 'schema': 'int'}},
}
{'type': 'typed-dict', 'extra_behavior': 'forbid', 'fields': {'field_a': {'alias': 'FieldA', 'schema': 'int'}}}
)
assert v.validate_test({'FieldA': 1}) == {'field_a': 1}

6 changes: 5 additions & 1 deletion tests/validators/test_union.py
Original file line number Diff line number Diff line change
@@ -225,7 +225,11 @@ def test_no_choices():
with pytest.raises(SchemaError) as exc_info:
SchemaValidator({'type': 'union'})

assert exc_info.value.args[0] == 'Error building "union" validator:\n KeyError: \'choices\''
assert exc_info.value.args[0] == (
'Invalid Schema:\n'
'union -> choices\n'
" Field required [kind=missing, input_value={'type': 'union'}, input_type=dict]"
)


def test_strict_union():

0 comments on commit 5c33ba0

Please sign in to comment.