8000 Add pipeline API by adriangb · Pull Request #9459 · pydantic/pydantic · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
e7eb43c
Add pipeline API
adriangb May 20, 2024
7d78407
port to python 3.10
adriangb May 20, 2024
6a1622d
port to python 3.10
adriangb May 20, 2024
947e920
fix syntax
adriangb May 20, 2024
b3fe1c4
handle slots
adriangb May 20, 2024
a6d56cc
Remove match
adriangb May 21, 2024
42505df
Remove match
adriangb May 21, 2024
b2855d0
ignore warning
adriangb May 21, 2024
aafb856
fix import
adriangb May 21, 2024
0bd8b39
fix union
adriangb May 21, 2024
697833f
fix union
adriangb May 21, 2024
d767731
sort imports
adriangb May 21, 2024
8abb6e4
move
adriangb May 21, 2024
217b11d
move
adriangb May 21, 2024
89c46a1
add missing file
adriangb May 21, 2024
6e91a32
namespace
adriangb May 23, 2024
8e4d535
initial tests
sydney-runkle May 29, 2024
ada5853
add more operators
adriangb May 30, 2024
8742e9e
Add json schema tests, add section mapping existing validators
adriangb May 31, 2024
f55b6e1
move things around for expeirmental pattern
sydney-runkle May 31, 2024
7132bae
fix docs tests
sydney-runkle May 31, 2024
0444fc9
maybe fix 3.9 test
sydney-runkle May 31, 2024
1a8e505
use typing Pattern
sydney-runkle May 31, 2024
d979841
add PydanticExperimentalWarning
sydney-runkle May 31, 2024
fadf3bb
ignore warnings, for some reason pytestmark wasn't working
sydney-runkle May 31, 2024
1699f35
3.8 friendly removesuffix
sydney-runkle May 31, 2024
d0a9372
Apply docs suggestions from code review
sydney-runkle Jun 4, 2024
bed0752
add __all__
adriangb Jun 4, 2024
eb61549
rename class to pipeline
adriangb Jun 4, 2024
a18a4df
get rid of on_lambda_err
adriangb Jun 4, 2024
34663fe
pr feedback
adriangb Jun 4, 2024
dff9ad9
make transform use the field type instead of any
adriangb Jun 4, 2024
479ab3c
add import
adriangb Jun 4, 2024
7b49219
rename parse() -> validate_as()
adriangb Jun 4, 2024
51bcad6
rename internal classes
adriangb Jun 4, 2024
13b1721
make Pipeline _Pipeline
adriangb Jun 4, 2024
b8573b5
Remove namespaces
adriangb Jun 4, 2024
888c4ed
more test
adriangb Jun 4, 2024
141c8b6
use ellipsis
sydney-runkle Jun 4, 2024
9d4194b
updating imports from internal test
sydney-runkle Jun 4, 2024
128d4ea
maybe fixing zoneinfo tests, switching up validate_as annotation again
sydney-runkle Jun 4, 2024
1c7302d
docs and linting
sydney-runkle Jun 4, 2024
88dcb75
removing tzinfo stuff :(
sydney-runkle Jun 5, 2024
19a3ee6
a bit more explanation
sydney-runkle Jun 5, 2024
0652472
api docs update
sydney-runkle Jun 5, 2024
4ccf4e5
Additional Test Cases for Experimental Pipeline API (#9566)
dAIsySHEng1 Jun 5, 2024
bad0a1a
fix common predicates + add tests
sydney-runkle Jun 5, 2024
a9d1099
remove unneeded line
sydney-runkle Jun 5, 2024
14e9944
update to version policy docs
sydney-runkle Jun 5, 2024
42a2708
skip linting
sydney-runkle Jun 5, 2024
021604f
fix type hint for _Pipeline.then
adriangb Jun 5, 2024
38a2730
Apply suggestions from code review
sydney-runkle Jun 5, 2024
0c36b7c
Update pydantic/experimental/pipeline.py
sydney-runkle Jun 5, 2024
8d46b21
add public todo
sydney-runkle Jun 5, 2024
a46c2e3
move predicate up
sydney-runkle Jun 5, 2024
7386d69
new idea for overload
sydney-runkle Jun 5, 2024
dc07b50
test fixes
sydney-runkle Jun 5, 2024
cbb216b
update test cases with comments
sydney-runkle Jun 5, 2024
581cbe8
no freeze notes
sydney-runkle Jun 5, 2024
c3a008f
suggested frozen change
sydney-runkle Jun 5, 2024
26c5325
add test
adriangb Jun 5, 2024
166df3d
add more assertions
adriangb Jun 5, 2024
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
Prev Previous commit
Next Next commit
Add json schema tests, add section mapping existing validators
  • Loading branch information
adriangb committed May 31, 2024
commit 8742e9e26daf054472b251accf3f265883e29e52
17 changes: 17 additions & 0 deletions docs/concepts/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,23 @@ class User(BaseModel):
7. For recursive types you can use `parse_defer` to reference the type itself before it's defined.
8. You can call `parse()` before or after other steps to do pre or post processing.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you explain what this means? I assume that this is interactions with BeforeValidator. How do we implement wrap validators in this model?

10000 Copy link
Member Author

Choose a reason for hiding this comment

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

The point is you can do Annotated[int, parse(str).transform(str.strip).parse().transform(lambda x: x * 2) which is a wrap validator since it does (1) validate as a string and strip whitespace, (2) parse as an integer and (3) multiple by 2.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I see. .parse() like that was totally not obvious to me, is it a significant ergonomic win to allow the argument to be inferred. Otherwise I might argue to make it required for now to simplify.

Or write an explicit section which is like

BeforeValidator -> parse(str).transform(bv).parse()
AfterValidator -> parse().transform(av)
WrapValidator -> parse(str).transform(bv).parse().transform(av)

... @sydney-runkle and I had an interesting use case for a wrap validator the other day which split IANA timezone off a timestamp so that we could do the timestamp in Rust and then reconstruct a tz-aware datetime. It would be interesting to see how to implement that in this API, as it's not obvious to me.

Copy link
Member Author

Choose a reason for hiding this comment

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

Could you elaborate on that example and we can try and see how it would work out?

Copy link
Member Author
@adriangb adriangb May 31, 2024

Choose a reason for hiding this comment

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

is it a significant ergonomic win to allow the argument to be inferred

I'd say so, consider if the type is list[MyGeneric[dict[str, set[bool]]]] or something...

Copy link
Member Author
@adriangb adriangb May 31, 2024

Choose a reason for hiding this comment

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

had an interesting use case for a wrap validator the other day which split IANA timezone off a timestamp so that we could do the timestamp in Rust and then reconstruct a tz-aware datetime

We could also add an explicit wrap validator: parse(str).transform(lambda x, h: h(x.strip())).parse(int)?

I don't really like that because it breaks the linearity and typing.


##### Mapping from `BeforeValidator`, `AfterValidator` and `WrapValidator`

The `parse` method is a more type-safe way to define `BeforeValidator`, `AfterValidator` and `WrapValidator`:

```python
# BeforeValidator
Annotated[int, parse(str).str.strip().parse()] # (1)!
# AfterValidator
Annotated[int, parse().transform(lambda x: x * 2)] # (2)!
# WrapValidator
Annotated[int, parse(str).str.strip().parse().transform(lambda x: x * 2)] # (3)!
```

1. Strip whitespace from a string before parsing it as an integer.
2. Multiply an integer by 2 after parsing it.
3. Strip whitespace from a string, parse it as an integer, then multiply it by 2.

#### Adding validation and serialization

You can add or override validation, serialization, and JSON schemas to an arbitrary type using the markers that
Expand Down
2 changes: 1 addition & 1 deletion pydantic/_internal/_generate_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1805,7 +1805,7 @@ def _apply_annotations(
pydantic_js_annotation_functions: list[GetJsonSchemaFunction] = []

def inner_handler(obj: Any) -> CoreSchema:
from_property = self._generate_schema_from_property(obj, obj)
from_property = self._generate_schema_from_property(obj, source_type)
if from_property is None:
schema = self._generate_schema_inner(obj)
else:
Expand Down
47 changes: 47 additions & 0 deletions pydantic/transform_experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,20 +398,44 @@ def check_gt(v: Any) -> bool:
s = _check_func(check_gt, f'> {gt}', s)
elif isinstance(constraint, annotated_types.Ge):
ge = constraint.ge
if s and s['type'] in ('int', 'float', 'decimal'):
s = s.copy()
if s['type'] == 'int' and isinstance(ge, int):
s['ge'] = ge
elif s['type'] == 'float' and isinstance(ge, float):
s['ge'] = ge
elif s['type'] == 'decimal' and isinstance(ge, Decimal):
s['ge'] = ge

def check_ge(v: Any) -> bool:
return v >= ge

s = _check_func(check_ge, f'>= {ge}', s)
elif isinstance(constraint, annotated_types.Lt):
lt = constraint.lt
if s and s['type'] in ('int', 'float', 'decimal'):
s = s.copy()
if s['type'] == 'int' and isinstance(lt, int):
s['lt'] = lt
elif s['type'] == 'float' and isinstance(lt, float):
s['lt'] = lt
elif s['type'] == 'decimal' and isinstance(lt, Decimal):
s['lt'] = lt

def check_lt(v: Any) -> bool:
return v < lt

s = _check_func(check_lt, f'< {lt}', s)
elif isinstance(constraint, annotated_types.Le):
le = constraint.le
if s and s['type'] in ('int', 'float', 'decimal'):
s = s.copy()
if s['type'] == 'int' and isinstance(le, int):
s['le'] = le
elif s['type'] == 'float' and isinstance(le, float):
s['le'] = le
elif s['type'] == 'decimal' and isinstance(le, Decimal):
s['le'] = le

def check_le(v: Any) -> bool:
return v <= le
Expand All @@ -421,6 +445,21 @@ def check_le(v: Any) -> bool:
min_len = constraint.min_length
max_len = constraint.max_length

if s and s['type'] in ('str', 'list', 'tuple', 'set', 'frozenset', 'dict'):
assert (
s['type'] == 'str'
or s['type'] == 'list'
or s['type'] == 'tuple'
or s['type'] == 'set'
or s['type'] == 'dict'
or s['type'] == 'frozenset'
)
s = s.copy()
if min_len != 0:
s['min_length'] = min_len
if max_len is not None:
s['max_length'] = max_len

def check_len(v: Any) -> bool:
if max_len is not None:
return (min_len <= len(v)) and (len(v) <= max_len)
Expand All @@ -429,6 +468,14 @@ def check_len(v: Any) -> bool:
s = _check_func(check_len, f'length >= {min_len} and length <= {max_len}', s)
elif isinstance(constraint, annotated_types.MultipleOf):
multiple_of = constraint.multiple_of
if s and s['type'] in ('int', 'float', 'decimal'):
s = s.copy()
if s['type'] == 'int' and isinstance(multiple_of, int):
s['multiple_of'] = multiple_of
elif s['type'] == 'float' and isinstance(multiple_of, float):
s['multiple_of'] = multiple_of
elif s['type'] == 'decimal' and isinstance(multiple_of, Decimal):
s['multiple_of'] = multiple_of

def check_multiple_of(v: Any) -> bool:
return v % multiple_of == 0
Expand Down
57 changes: 55 additions & 2 deletions tests/test_transform.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the experimental transform module."""
from __future__ import annotations

from typing import Optional
from typing import Any

import pytest
from typing_extensions import Annotated
Expand Down Expand Up @@ -38,7 +39,7 @@ def test_parse_str_with_pattern() -> None:
('ends_with', 'ato', 'potato', 'potato'),
],
)
def test_string_validator_valid(method: str, method_arg: Optional[str], input_string: str, expected_output: str):
def test_string_validator_valid(method: str, method_arg: str | None, input_string: str, expected_output: str):
# annotated metadata is equivalent to parse(str).str.method(method_arg)
# ex: parse(str).str.contains('pot')
annotated_metadata = getattr(parse(str).str, method)
Expand Down Expand Up @@ -95,3 +96,55 @@ def test_predicates() -> None:
assert ta_str.validate_python('tomato') == 'tomato'
with pytest.raises(ValidationError):
ta_str.validate_python('potato')


@pytest.mark.parametrize(
'model, expected_val_schema, expected_ser_schema',
[
(
Annotated[int | str, parse() | parse(str)],
{'anyOf': [{'type': 'integer'}, {'type': 'string'}]},
{'anyOf': [{'type': 'integer'}, {'type': 'string'}]},
),
(
Annotated[int, parse() | parse(str).parse(int)],
{'anyOf': [{'type': 'integer'}, {'type': 'string'}]},
{'type': 'integer'},
),
(
Annotated[int, parse() | parse(str).transform(int)],
{'anyOf': [{'type': 'integer'}, {'type': 'string'}]},
{'anyOf': [{'type': 'integer'}, {'type': 'string'}]},
),
(
Annotated[int, parse() | parse(str).transform(int).parse(int)],
{'anyOf': [{'type': 'integer'}, {'type': 'string'}]},
{'type': 'integer'},
),
(
Annotated[int, parse(int).gt(0).lt(100)],
{'type': 'integer', 'exclusiveMinimum': 0, 'exclusiveMaximum': 100},
{'type': 'integer', 'exclusiveMinimum': 0, 'exclusiveMaximum': 100},
),
(
Annotated[int, parse(int).gt(0) | parse(int).lt(100)],
{'anyOf': [{'type': 'integer', 'exclusiveMinimum': 0}, {'type': 'integer', 'exclusiveMaximum': 100}]},
{'anyOf': [{'type': 'integer', 'exclusiveMinimum': 0}, {'type': 'integer', 'exclusiveMaximum': 100}]},
),
(
Annotated[list[int], parse().len(0, 100)],
{'type': 'array', 'items': {'type': 'integer'}, 'maxItems': 100},
{'type': 'array', 'items': {'type': 'integer'}, 'maxItems': 100},
),
],
)
def test_json_schema(
model: type[Any], expected_val_schema: dict[str, Any], expected_ser_schema: dict[str, Any]
) -> None:
ta = TypeAdapter(model)

schema = ta.json_schema(mode='validation')
assert schema == expected_val_schema

schema = ta.json_schema(mode='serialization')
assert schema == expected_ser_schema
0