Skip to content

Commit a423e15

Browse files
authored
Add optional schema kwarg support (python-benedict[schema] (pydantic v2)) to all from_* and to_* IO methods. (#565)
1 parent 4073f83 commit a423e15

7 files changed

Lines changed: 565 additions & 2 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ python-benedict is a dict subclass with **keylist/keypath/keyattr** support, **I
2424
- **Keypath** support using **keypath-separator** *(dot syntax by default)*.
2525
- Keypath **list-index** support *(also negative)* using the standard `[n]` suffix.
2626
- Normalized **I/O operations** with most common formats: `base64`, `cli`, `csv`, `html`, `ini`, `json`, `pickle`, `plist`, `query-string`, `toml`, `xls`, `xml`, `yaml`.
27+
- `NEW` Optional **Pydantic v2 schema** validation and type coercion on all `from_*` / `to_*` I/O methods via the `schema` kwarg *(requires `python-benedict[schema]`).*
2728
- Multiple **I/O operations** backends: `file-system` *(read/write)*, `url` *(read-only)*, `s3` *(read/write)*.
2829
- Many **utility** and **parse methods** to retrieve data as needed *(check the [API](#api) section)*.
2930
- Well **tested**. ;)
@@ -68,6 +69,7 @@ Here the hierarchy of possible installation targets available when running `pip
6869
- `[yaml]`
6970
- `[parse]`
7071
- `[s3]`
72+
- `[schema]`
7173

7274
## Usage
7375

@@ -614,6 +616,29 @@ d.unique()
614616

615617
These methods are available for input/output operations.
616618

619+
All `from_*` and `to_*` methods accept an optional `schema` keyword argument. When a [Pydantic v2](https://docs.pydantic.dev/) model class is passed, the data is validated and type-coerced through the model before being returned (on decode) or serialized (on encode). This requires the `python-benedict[schema]` extra.
620+
621+
```
622+
pip install "python-benedict[schema]"
623+
```
624+
625+
```python
626+
from benedict import benedict
627+
from pydantic import BaseModel
628+
629+
class User(BaseModel):
630+
name: str
631+
age: int
632+
633+
# validate and coerce types on decode
634+
d = benedict.from_json('{"name": "Alice", "age": "30"}', schema=User)
635+
assert d["age"] == 30 # coerced from str to int
636+
637+
# validate and coerce types on encode
638+
d = benedict({"name": "Bob", "age": "25"})
639+
s = d.to_json(schema=User) # age is coerced to int before serialization
640+
```
641+
617642
#### `from_base64`
618643

619644
```python
@@ -666,6 +691,7 @@ d = benedict.from_html(s, **kwargs)
666691
# Accept as first argument: url, filepath or data-string.
667692
# It's possible to pass decoder specific options using kwargs:
668693
# https://docs.python.org/3/library/configparser.html
694+
# It's possible to pass a Pydantic v2 model class as schema= to validate and coerce data.
669695
# A ValueError is raised in case of failure.
670696
d = benedict.from_ini(s, **kwargs)
671697
```
@@ -677,6 +703,7 @@ d = benedict.from_ini(s, **kwargs)
677703
# Accept as first argument: url, filepath or data-string.
678704
# It's possible to pass decoder specific options using kwargs:
679705
# https://docs.python.org/3/library/json.html
706+
# It's possible to pass a Pydantic v2 model class as schema= to validate and coerce data.
680707
# A ValueError is raised in case of failure.
681708
d = benedict.from_json(s, **kwargs)
682709
```
@@ -753,6 +780,7 @@ d = benedict.from_xml(s, **kwargs)
753780
# Accept as first argument: url, filepath or data-string.
754781
# It's possible to pass decoder specific options using kwargs:
755782
# https://pyyaml.org/wiki/PyYAMLDocumentation
783+
# It's possible to pass a Pydantic v2 model class as schema= to validate and coerce data.
756784
# A ValueError is raised in case of failure.
757785
d = benedict.from_yaml(s, **kwargs)
758786
```
@@ -795,6 +823,7 @@ s = d.to_ini(**kwargs)
795823
# Return the dict instance encoded in json format and optionally save it at the specified filepath.
796824
# It's possible to pass encoder specific options using kwargs:
797825
# https://docs.python.org/3/library/json.html
826+
# It's possible to pass a Pydantic v2 model class as schema= to validate and coerce data before encoding.
798827
# A ValueError is raised in case of failure.
799828
s = d.to_json(**kwargs)
800829
```

benedict/dicts/io/io_util.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
get_format_by_path,
2828
get_serializer_by_format,
2929
)
30-
from benedict.utils import type_util
30+
from benedict.utils import schema_util, type_util
3131

3232

3333
def autodetect_format(s: Any) -> str | None:
@@ -61,10 +61,13 @@ def decode(s: Any, format: str, **kwargs: Any) -> Any:
6161
if not serializer:
6262
raise ValueError(f"Invalid format: {format}.")
6363
options = kwargs.copy()
64+
schema = options.pop("schema", None)
6465
if format in ["b64", "base64"]:
6566
options.setdefault("subformat", "json")
6667
content = read_content(s, format, options)
6768
data = serializer.decode(content, **options)
69+
if schema is not None:
70+
data = schema_util.apply_schema(data, schema)
6871
return data
6972

7073

@@ -73,6 +76,9 @@ def encode(d: Any, format: str, filepath: str | None = None, **kwargs: Any) -> A
7376
if not serializer:
7477
raise ValueError(f"Invalid format: {format}.")
7578
options = kwargs.copy()
79+
schema = options.pop("schema", None)
80+
if schema is not None:
81+
d = schema_util.apply_schema(d, schema)
7682
content = serializer.encode(d, **options)
7783
if filepath:
7884
filepath = str(filepath)

benedict/extras.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"require_html",
66
"require_parse",
77
"require_s3",
8+
"require_schema",
89
"require_toml",
910
"require_xls",
1011
"require_xml",
@@ -33,6 +34,10 @@ def require_s3(*, installed: bool) -> None:
3334
_require_optional_dependencies(target="s3", installed=installed)
3435

3536

37+
def require_schema(*, installed: bool) -> None:
38+
_require_optional_dependencies(target="schema", installed=installed)
39+
40+
3641
def require_toml(*, installed: bool) -> None:
3742
_require_optional_dependencies(target="toml", installed=installed)
3843

benedict/utils/schema_util.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
try:
6+
import pydantic
7+
8+
pydantic_installed = True
9+
except ImportError: # pragma: no cover
10+
pydantic_installed = False
11+
12+
13+
def apply_schema(data: Any, schema: Any) -> Any:
14+
"""
15+
Validate and parse data using a Pydantic model class.
16+
Returns the validated data as a plain dict.
17+
Raises ExtrasRequireModuleNotFoundError if pydantic is not installed.
18+
Raises TypeError if schema is not a pydantic BaseModel subclass.
19+
"""
20+
from benedict.extras import require_schema
21+
22+
require_schema(installed=pydantic_installed)
23+
if isinstance(schema, type) and issubclass(schema, pydantic.BaseModel):
24+
schema_cls: type[pydantic.BaseModel] = schema
25+
else:
26+
raise TypeError(
27+
f"schema must be a pydantic BaseModel subclass, got {type(schema)!r}"
28+
)
29+
instance = schema_cls.model_validate(data)
30+
return instance.model_dump()

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Twitter = "https://twitter.com/fabiocaccamo"
116116

117117
[project.optional-dependencies]
118118
all = [
119-
"python-benedict[io,parse,s3]",
119+
"python-benedict[io,parse,s3,schema]",
120120
]
121121
html = [
122122
"beautifulsoup4 >= 4.12.0, < 5.0.0",
@@ -137,6 +137,9 @@ s3 = [
137137
"boto3 >= 1.24.89, < 2.0.0",
138138
"python-fsutil >= 0.16.1, < 1.0.0",
139139
]
140+
schema = [
141+
"pydantic >= 2.0.0, < 3.0.0",
142+
]
140143
toml = [
141144
"toml >= 0.10.2, < 1.0.0",
142145
]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ idna >= 3.7
66
mailchecker == 6.0.20
77
openpyxl == 3.1.5
88
phonenumbers == 9.0.27
9+
pydantic >= 2.0.0, < 3.0.0
910
python-dateutil == 2.9.0.post0
1011
python-fsutil == 0.16.1
1112
python-slugify == 8.0.4

0 commit comments

Comments
 (0)