Skip to content

Commit 7739dea

Browse files
committed
fix(toml): resolve merge conflict with upstream main (#568)
2 parents a5fe910 + a423e15 commit 7739dea

15 files changed

Lines changed: 660 additions & 28 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/core/items_sorted.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
from __future__ import annotations
22

33
from collections.abc import Mapping
4-
5-
from useful_types import SupportsRichComparisonT
4+
from typing import Any
65

76

87
def _items_sorted_by_item_at_index(
9-
d: Mapping[SupportsRichComparisonT, SupportsRichComparisonT],
8+
d: Mapping[Any, Any],
109
index: int,
1110
reverse: bool,
12-
) -> list[tuple[SupportsRichComparisonT, SupportsRichComparisonT]]:
11+
) -> list[tuple[Any, Any]]:
1312
return sorted(d.items(), key=lambda item: item[index], reverse=reverse)
1413

1514

1615
def items_sorted_by_keys(
17-
d: Mapping[SupportsRichComparisonT, SupportsRichComparisonT], reverse: bool = False
18-
) -> list[tuple[SupportsRichComparisonT, SupportsRichComparisonT]]:
16+
d: Mapping[Any, Any], reverse: bool = False
17+
) -> list[tuple[Any, Any]]:
1918
return _items_sorted_by_item_at_index(d, 0, reverse)
2019

2120

2221
def items_sorted_by_values(
23-
d: Mapping[SupportsRichComparisonT, SupportsRichComparisonT], reverse: bool = False
24-
) -> list[tuple[SupportsRichComparisonT, SupportsRichComparisonT]]:
22+
d: Mapping[Any, Any], reverse: bool = False
23+
) -> list[tuple[Any, Any]]:
2524
return _items_sorted_by_item_at_index(d, 1, reverse)

benedict/dicts/io/io_util.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,22 @@
1212
import boto3
1313

1414
s3_installed = True
15-
except ModuleNotFoundError:
15+
except ModuleNotFoundError: # pragma: no cover
1616
s3_installed = False
1717

18-
import fsutil
18+
try:
19+
import fsutil
20+
21+
fsutil_installed = True
22+
except ModuleNotFoundError: # pragma: no cover
23+
fsutil_installed = False
1924

20-
from benedict.extras import require_s3
25+
from benedict.extras import require_fsutil, require_s3
2126
from benedict.serializers import (
2227
get_format_by_path,
2328
get_serializer_by_format,
2429
)
25-
from benedict.utils import type_util
30+
from benedict.utils import schema_util, type_util
2631

2732

2833
def autodetect_format(s: Any) -> str | None:
@@ -56,10 +61,13 @@ def decode(s: Any, format: str, **kwargs: Any) -> Any:
5661
if not serializer:
5762
raise ValueError(f"Invalid format: {format}.")
5863
options = kwargs.copy()
64+
schema = options.pop("schema", None)
5965
if format in ["b64", "base64"]:
6066
options.setdefault("subformat", "json")
6167
content = read_content(s, format, options)
6268
data = serializer.decode(content, **options)
69+
if schema is not None:
70+
data = schema_util.apply_schema(data, schema)
6371
return data
6472

6573

@@ -68,6 +76,9 @@ def encode(d: Any, format: str, filepath: str | None = None, **kwargs: Any) -> A
6876
if not serializer:
6977
raise ValueError(f"Invalid format: {format}.")
7078
options = kwargs.copy()
79+
schema = options.pop("schema", None)
80+
if schema is not None:
81+
d = schema_util.apply_schema(d, schema)
7182
content = serializer.encode(d, **options)
7283
if filepath:
7384
filepath = str(filepath)
@@ -88,7 +99,7 @@ def is_data(s: str | bytes) -> bool:
8899

89100

90101
def is_filepath(s: Path | str) -> bool:
91-
if fsutil.is_file(s):
102+
if fsutil_installed and fsutil.is_file(s):
92103
return True
93104
return bool(
94105
get_format_by_path(s)
@@ -147,15 +158,18 @@ def read_content(
147158

148159

149160
def read_content_from_file(filepath: str, format: str | None = None) -> str:
161+
require_fsutil(installed=fsutil_installed)
150162
binary_format = is_binary_format(format)
151163
if binary_format:
152164
return filepath
153-
return fsutil.read_file(filepath) # type: ignore[no-any-return]
165+
content = fsutil.read_file(filepath)
166+
return str(content)
154167

155168

156169
def read_content_from_s3(
157170
url: str, s3_options: Mapping[str, Any], format: str | None = None
158171
) -> str:
172+
require_fsutil(installed=fsutil_installed)
159173
require_s3(installed=s3_installed)
160174
s3_url = parse_s3_url(url)
161175
dirpath = tempfile.gettempdir()
@@ -171,12 +185,14 @@ def read_content_from_s3(
171185
def read_content_from_url(
172186
url: str, requests_options: Mapping[str, Any], format: str | None = None
173187
) -> str:
188+
require_fsutil(installed=fsutil_installed)
174189
binary_format = is_binary_format(format)
175190
if binary_format:
176191
dirpath = tempfile.gettempdir()
177192
filepath = fsutil.download_file(url, dirpath=dirpath, **requests_options)
178-
return filepath # type: ignore[no-any-return]
179-
return fsutil.read_file_from_url(url, **requests_options) # type: ignore[no-any-return]
193+
return str(filepath)
194+
content = fsutil.read_file_from_url(url, **requests_options)
195+
return str(content)
180196

181197

182198
def write_content(filepath: str, content: str, **options: Any) -> None:
@@ -187,12 +203,14 @@ def write_content(filepath: str, content: str, **options: Any) -> None:
187203

188204

189205
def write_content_to_file(filepath: str, content: str, **options: Any) -> None:
206+
require_fsutil(installed=fsutil_installed)
190207
fsutil.write_file(filepath, content)
191208

192209

193210
def write_content_to_s3(
194211
url: str, content: str, s3_options: Mapping[str, Any], **options: Any
195212
) -> None:
213+
require_fsutil(installed=fsutil_installed)
196214
require_s3(installed=s3_installed)
197215
s3_url = parse_s3_url(url)
198216
dirpath = tempfile.gettempdir()

benedict/dicts/parse/parse_util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from phonenumbers import PhoneNumberFormat, phonenumberutil
1313

1414
parse_installed = True
15-
except ModuleNotFoundError:
15+
except ModuleNotFoundError: # pragma: no cover
1616
parse_installed = False
1717

1818

benedict/extras.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from benedict.exceptions import ExtrasRequireModuleNotFoundError
22

33
__all__ = [
4+
"require_fsutil",
45
"require_html",
56
"require_parse",
67
"require_s3",
8+
"require_schema",
79
"require_toml",
810
"require_xls",
911
"require_xml",
@@ -20,6 +22,10 @@ def require_html(*, installed: bool) -> None:
2022
_require_optional_dependencies(target="html", installed=installed)
2123

2224

25+
def require_fsutil(*, installed: bool) -> None:
26+
_require_optional_dependencies(target="io", installed=installed)
27+
28+
2329
def require_parse(*, installed: bool) -> None:
2430
_require_optional_dependencies(target="parse", installed=installed)
2531

@@ -28,6 +34,10 @@ def require_s3(*, installed: bool) -> None:
2834
_require_optional_dependencies(target="s3", installed=installed)
2935

3036

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

benedict/serializers/html.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from bs4 import BeautifulSoup
55

66
html_installed = True
7-
except ModuleNotFoundError:
7+
except ModuleNotFoundError: # pragma: no cover
88
html_installed = False
99

1010
from typing import Any, NoReturn

benedict/serializers/xls.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
from __future__ import annotations
22

3-
import fsutil
3+
try:
4+
import fsutil
5+
6+
fsutil_installed = True
7+
except ModuleNotFoundError: # pragma: no cover
8+
fsutil_installed = False
49

510
try:
611
from openpyxl import load_workbook
712
from xlrd import open_workbook
813

914
xls_installed = True
10-
except ModuleNotFoundError:
15+
except ModuleNotFoundError: # pragma: no cover
1116
xls_installed = False
1217

1318
from collections.abc import Sequence
1419
from typing import Any, NoReturn
1520

1621
from slugify import slugify
1722

18-
from benedict.extras import require_xls
23+
from benedict.extras import require_fsutil, require_xls
1924
from benedict.serializers.abstract import AbstractSerializer
2025

2126

@@ -175,6 +180,7 @@ def _decode(self, s: str, **kwargs: Any) -> list[dict[str, Any]]:
175180

176181
def decode(self, s: str, **kwargs: Any) -> list[dict[str, Any]]:
177182
require_xls(installed=xls_installed)
183+
require_fsutil(installed=fsutil_installed)
178184
extension = fsutil.get_file_extension(s)
179185
if extension in ["xlsx", "xlsm"]:
180186
return self._decode(s, **kwargs)

benedict/serializers/xml.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import xmltodict
55

66
xml_installed = True
7-
except ModuleNotFoundError:
7+
except ModuleNotFoundError: # pragma: no cover
88
xml_installed = False
99

1010

benedict/serializers/yaml.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from yaml.representer import SafeRepresenter
77

88
yaml_installed = True
9-
except ModuleNotFoundError:
9+
except ModuleNotFoundError: # pragma: no cover
1010
yaml_installed = False
1111

1212

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()

0 commit comments

Comments
 (0)