diff --git a/README.md b/README.md index 3a61ddad..e4b5ae38 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ python-benedict is a dict subclass with **keylist/keypath/keyattr** support, **I - **Keypath** support using **keypath-separator** *(dot syntax by default)*. - Keypath **list-index** support *(also negative)* using the standard `[n]` suffix. - Normalized **I/O operations** with most common formats: `base64`, `cli`, `csv`, `html`, `ini`, `json`, `pickle`, `plist`, `query-string`, `toml`, `xls`, `xml`, `yaml`. +- `NEW` Optional **Pydantic v2 schema** validation and type coercion on all `from_*` / `to_*` I/O methods via the `schema` kwarg *(requires `python-benedict[schema]`).* - Multiple **I/O operations** backends: `file-system` *(read/write)*, `url` *(read-only)*, `s3` *(read/write)*. - Many **utility** and **parse methods** to retrieve data as needed *(check the [API](#api) section)*. - Well **tested**. ;) @@ -68,6 +69,7 @@ Here the hierarchy of possible installation targets available when running `pip - `[yaml]` - `[parse]` - `[s3]` + - `[schema]` ## Usage @@ -614,6 +616,29 @@ d.unique() These methods are available for input/output operations. +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. + +``` +pip install "python-benedict[schema]" +``` + +```python +from benedict import benedict +from pydantic import BaseModel + +class User(BaseModel): + name: str + age: int + +# validate and coerce types on decode +d = benedict.from_json('{"name": "Alice", "age": "30"}', schema=User) +assert d["age"] == 30 # coerced from str to int + +# validate and coerce types on encode +d = benedict({"name": "Bob", "age": "25"}) +s = d.to_json(schema=User) # age is coerced to int before serialization +``` + #### `from_base64` ```python @@ -666,6 +691,7 @@ d = benedict.from_html(s, **kwargs) # Accept as first argument: url, filepath or data-string. # It's possible to pass decoder specific options using kwargs: # https://docs.python.org/3/library/configparser.html +# It's possible to pass a Pydantic v2 model class as schema= to validate and coerce data. # A ValueError is raised in case of failure. d = benedict.from_ini(s, **kwargs) ``` @@ -677,6 +703,7 @@ d = benedict.from_ini(s, **kwargs) # Accept as first argument: url, filepath or data-string. # It's possible to pass decoder specific options using kwargs: # https://docs.python.org/3/library/json.html +# It's possible to pass a Pydantic v2 model class as schema= to validate and coerce data. # A ValueError is raised in case of failure. d = benedict.from_json(s, **kwargs) ``` @@ -753,6 +780,7 @@ d = benedict.from_xml(s, **kwargs) # Accept as first argument: url, filepath or data-string. # It's possible to pass decoder specific options using kwargs: # https://pyyaml.org/wiki/PyYAMLDocumentation +# It's possible to pass a Pydantic v2 model class as schema= to validate and coerce data. # A ValueError is raised in case of failure. d = benedict.from_yaml(s, **kwargs) ``` @@ -795,6 +823,7 @@ s = d.to_ini(**kwargs) # Return the dict instance encoded in json format and optionally save it at the specified filepath. # It's possible to pass encoder specific options using kwargs: # https://docs.python.org/3/library/json.html +# It's possible to pass a Pydantic v2 model class as schema= to validate and coerce data before encoding. # A ValueError is raised in case of failure. s = d.to_json(**kwargs) ``` diff --git a/benedict/dicts/io/io_util.py b/benedict/dicts/io/io_util.py index 5ec10a23..3156c7b2 100644 --- a/benedict/dicts/io/io_util.py +++ b/benedict/dicts/io/io_util.py @@ -27,7 +27,7 @@ get_format_by_path, get_serializer_by_format, ) -from benedict.utils import type_util +from benedict.utils import schema_util, type_util def autodetect_format(s: Any) -> str | None: @@ -61,10 +61,13 @@ def decode(s: Any, format: str, **kwargs: Any) -> Any: if not serializer: raise ValueError(f"Invalid format: {format}.") options = kwargs.copy() + schema = options.pop("schema", None) if format in ["b64", "base64"]: options.setdefault("subformat", "json") content = read_content(s, format, options) data = serializer.decode(content, **options) + if schema is not None: + data = schema_util.apply_schema(data, schema) return data @@ -73,6 +76,9 @@ def encode(d: Any, format: str, filepath: str | None = None, **kwargs: Any) -> A if not serializer: raise ValueError(f"Invalid format: {format}.") options = kwargs.copy() + schema = options.pop("schema", None) + if schema is not None: + d = schema_util.apply_schema(d, schema) content = serializer.encode(d, **options) if filepath: filepath = str(filepath) diff --git a/benedict/extras.py b/benedict/extras.py index f69c3eb3..6f09db6b 100644 --- a/benedict/extras.py +++ b/benedict/extras.py @@ -5,6 +5,7 @@ "require_html", "require_parse", "require_s3", + "require_schema", "require_toml", "require_xls", "require_xml", @@ -33,6 +34,10 @@ def require_s3(*, installed: bool) -> None: _require_optional_dependencies(target="s3", installed=installed) +def require_schema(*, installed: bool) -> None: + _require_optional_dependencies(target="schema", installed=installed) + + def require_toml(*, installed: bool) -> None: _require_optional_dependencies(target="toml", installed=installed) diff --git a/benedict/utils/schema_util.py b/benedict/utils/schema_util.py new file mode 100644 index 00000000..1bcda513 --- /dev/null +++ b/benedict/utils/schema_util.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +try: + import pydantic + + pydantic_installed = True +except ImportError: # pragma: no cover + pydantic_installed = False + + +def apply_schema(data: Any, schema: Any) -> Any: + """ + Validate and parse data using a Pydantic model class. + Returns the validated data as a plain dict. + Raises ExtrasRequireModuleNotFoundError if pydantic is not installed. + Raises TypeError if schema is not a pydantic BaseModel subclass. + """ + from benedict.extras import require_schema + + require_schema(installed=pydantic_installed) + if isinstance(schema, type) and issubclass(schema, pydantic.BaseModel): + schema_cls: type[pydantic.BaseModel] = schema + else: + raise TypeError( + f"schema must be a pydantic BaseModel subclass, got {type(schema)!r}" + ) + instance = schema_cls.model_validate(data) + return instance.model_dump() diff --git a/pyproject.toml b/pyproject.toml index 7cc48cca..b349144b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ Twitter = "https://twitter.com/fabiocaccamo" [project.optional-dependencies] all = [ - "python-benedict[io,parse,s3]", + "python-benedict[io,parse,s3,schema]", ] html = [ "beautifulsoup4 >= 4.12.0, < 5.0.0", @@ -137,6 +137,9 @@ s3 = [ "boto3 >= 1.24.89, < 2.0.0", "python-fsutil >= 0.16.1, < 1.0.0", ] +schema = [ + "pydantic >= 2.0.0, < 3.0.0", +] toml = [ "toml >= 0.10.2, < 1.0.0", ] diff --git a/requirements.txt b/requirements.txt index c80163e4..c1643f52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ idna >= 3.7 mailchecker == 6.0.20 openpyxl == 3.1.5 phonenumbers == 9.0.27 +pydantic >= 2.0.0, < 3.0.0 python-dateutil == 2.9.0.post0 python-fsutil == 0.16.1 python-slugify == 8.0.4 diff --git a/tests/dicts/io/test_schema.py b/tests/dicts/io/test_schema.py new file mode 100644 index 00000000..9088639d --- /dev/null +++ b/tests/dicts/io/test_schema.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +import base64 +import importlib +import json +import pickle +import plistlib +import sys +import unittest +from unittest.mock import patch + +import pydantic + +from benedict import benedict +from benedict.dicts.io import IODict +from benedict.exceptions import ExtrasRequireModuleNotFoundError +from benedict.utils import schema_util + + +class UserSchema(pydantic.BaseModel): + name: str + age: int + + +class XMLWrappedSchema(pydantic.BaseModel): + root: UserSchema + + +class schema_util_test_case(unittest.TestCase): + def test_apply_schema_valid(self) -> None: + data = {"name": "Alice", "age": 30} + result = schema_util.apply_schema(data, UserSchema) + self.assertEqual(result, {"name": "Alice", "age": 30}) + + def test_apply_schema_coerces_types(self) -> None: + data = {"name": "Bob", "age": "25"} + result = schema_util.apply_schema(data, UserSchema) + self.assertEqual(result, {"name": "Bob", "age": 25}) + self.assertIsInstance(result["age"], int) + + def test_apply_schema_strips_extra_fields(self) -> None: + # Fields not declared in the schema must be absent from the result. + data = {"name": "Alice", "age": 30, "email": "alice@example.com", "extra": 42} + result = schema_util.apply_schema(data, UserSchema) + self.assertEqual(result, {"name": "Alice", "age": 30}) + self.assertNotIn("email", result) + self.assertNotIn("extra", result) + + def test_apply_schema_missing_required_field_raises(self) -> None: + # A missing required field must raise a ValidationError. + data = {"name": "Alice"} # 'age' is required but absent + with self.assertRaises(pydantic.ValidationError): + schema_util.apply_schema(data, UserSchema) + + def test_apply_schema_invalid_data_raises(self) -> None: + data = {"name": "Charlie", "age": "not-a-number"} + with self.assertRaises(pydantic.ValidationError): + schema_util.apply_schema(data, UserSchema) + + def test_apply_schema_pydantic_not_installed_raises(self) -> None: + with patch.object(schema_util, "pydantic_installed", False): + with self.assertRaises(ExtrasRequireModuleNotFoundError): + schema_util.apply_schema({"name": "X", "age": 1}, UserSchema) + + def test_apply_schema_non_class_raises_type_error(self) -> None: + with self.assertRaises(TypeError): + schema_util.apply_schema({"name": "Alice", "age": 30}, "not-a-class") + + def test_apply_schema_non_basemodel_class_raises_type_error(self) -> None: + class NotAModel: + pass + + with self.assertRaises(TypeError): + schema_util.apply_schema({"name": "Alice", "age": 30}, NotAModel) + + def test_apply_schema_instance_raises_type_error(self) -> None: + instance = UserSchema(name="Alice", age=30) + with self.assertRaises(TypeError): + schema_util.apply_schema({"name": "Alice", "age": 30}, instance) + + def test_pydantic_not_installed_at_import_time(self) -> None: + module_key = "benedict.utils.schema_util" + original_module = sys.modules.pop(module_key, None) + try: + with patch.dict("sys.modules", {"pydantic": None}): + reimported = importlib.import_module(module_key) + self.assertFalse(reimported.pydantic_installed) + finally: + if original_module is not None: + sys.modules[module_key] = original_module + + +class schema_extras_test_case(unittest.TestCase): + def test_require_schema_error_message(self) -> None: + from benedict.extras import require_schema + + with self.assertRaises(ExtrasRequireModuleNotFoundError) as ctx: + require_schema(installed=False) + self.assertIn("schema", str(ctx.exception)) + self.assertIn("python-benedict[schema]", str(ctx.exception)) + + +class io_dict_from_json_schema_test_case(unittest.TestCase): + def test_from_json_with_schema_valid(self) -> None: + j = '{"name": "Alice", "age": 30}' + d = IODict.from_json(j, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + + def test_from_json_with_schema_coerces_types(self) -> None: + j = '{"name": "Bob", "age": "25"}' + d = IODict.from_json(j, schema=UserSchema) + self.assertIsInstance(d["age"], int) + self.assertEqual(d["age"], 25) + + def test_from_json_with_schema_invalid_raises(self) -> None: + j = '{"name": "Charlie", "age": "not-a-number"}' + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_json(j, schema=UserSchema) + + def test_from_json_with_schema_strips_extra_fields(self) -> None: + j = '{"name": "Alice", "age": 30, "email": "alice@example.com"}' + d = IODict.from_json(j, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertNotIn("email", d) + + def test_from_json_with_schema_missing_required_field_raises(self) -> None: + j = '{"name": "Alice"}' # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_json(j, schema=UserSchema) + + def test_from_json_without_schema(self) -> None: + j = '{"name": "Alice", "age": 30}' + d = IODict.from_json(j) + self.assertEqual(d, {"name": "Alice", "age": 30}) + + +class io_dict_from_yaml_schema_test_case(unittest.TestCase): + def test_from_yaml_with_schema_valid(self) -> None: + y = "name: Alice\nage: 30\n" + d = IODict.from_yaml(y, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + + def test_from_yaml_with_schema_strips_extra_fields(self) -> None: + y = "name: Alice\nage: 30\nemail: alice@example.com\n" + d = IODict.from_yaml(y, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertNotIn("email", d) + + def test_from_yaml_with_schema_missing_required_field_raises(self) -> None: + y = "name: Alice\n" # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_yaml(y, schema=UserSchema) + + +class io_dict_from_ini_schema_test_case(unittest.TestCase): + def test_from_ini_with_schema_valid(self) -> None: + ini = "[DEFAULT]\nname = Alice\nage = 30\n" + d = IODict.from_ini(ini, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertIsInstance(d["age"], int) + + def test_from_ini_with_schema_strips_extra_fields(self) -> None: + ini = "[DEFAULT]\nname = Alice\nage = 30\nemail = alice@example.com\n" + d = IODict.from_ini(ini, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertNotIn("email", d) + + def test_from_ini_with_schema_missing_required_field_raises(self) -> None: + ini = "[DEFAULT]\nname = Alice\n" # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_ini(ini, schema=UserSchema) + + +class io_dict_to_json_schema_test_case(unittest.TestCase): + def test_to_json_with_schema_valid(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_json(schema=UserSchema) + self.assertIn('"name": "Alice"', result) + self.assertIn('"age": 30', result) + + def test_to_json_with_schema_coerces_types(self) -> None: + d = benedict({"name": "Bob", "age": "25"}) + result = d.to_json(schema=UserSchema) + parsed = json.loads(result) + self.assertEqual(parsed["age"], 25) + self.assertIsInstance(parsed["age"], int) + + def test_to_json_with_schema_invalid_raises(self) -> None: + d = benedict({"name": "Charlie", "age": "not-a-number"}) + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_json(schema=UserSchema) + + def test_to_json_with_schema_strips_extra_fields(self) -> None: + d = benedict({"name": "Alice", "age": 30, "email": "alice@example.com"}) + result = d.to_json(schema=UserSchema) + parsed = json.loads(result) + self.assertEqual(parsed, {"name": "Alice", "age": 30}) + self.assertNotIn("email", parsed) + + def test_to_json_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"name": "Alice"}) # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_json(schema=UserSchema) + + def test_to_json_without_schema(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_json() + self.assertIn('"name": "Alice"', result) + + +class io_dict_from_toml_schema_test_case(unittest.TestCase): + def test_from_toml_with_schema_valid(self) -> None: + t = 'name = "Alice"\nage = 30\n' + d = IODict.from_toml(t, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + + def test_from_toml_with_schema_strips_extra_fields(self) -> None: + t = 'name = "Alice"\nage = 30\nemail = "alice@example.com"\n' + d = IODict.from_toml(t, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertNotIn("email", d) + + def test_from_toml_with_schema_missing_required_field_raises(self) -> None: + t = 'name = "Alice"\n' # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_toml(t, schema=UserSchema) + + +class io_dict_from_xml_schema_test_case(unittest.TestCase): + def test_from_xml_with_schema_valid(self) -> None: + x = "Alice30" + d = IODict.from_xml(x, schema=XMLWrappedSchema) + self.assertEqual(d, {"root": {"name": "Alice", "age": 30}}) + + def test_from_xml_with_schema_strips_extra_fields(self) -> None: + x = "Alice30alice@example.com" + d = IODict.from_xml(x, schema=XMLWrappedSchema) + self.assertEqual(d, {"root": {"name": "Alice", "age": 30}}) + self.assertNotIn("email", d.get("root", {})) + + def test_from_xml_with_schema_missing_required_field_raises(self) -> None: + x = "Alice" # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_xml(x, schema=XMLWrappedSchema) + + +class io_dict_from_base64_schema_test_case(unittest.TestCase): + def test_from_base64_with_schema_valid(self) -> None: + s = base64.b64encode(json.dumps({"name": "Alice", "age": 30}).encode()).decode() + d = IODict.from_base64(s, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + + def test_from_base64_with_schema_strips_extra_fields(self) -> None: + s = base64.b64encode( + json.dumps( + {"name": "Alice", "age": 30, "email": "alice@example.com"} + ).encode() + ).decode() + d = IODict.from_base64(s, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertNotIn("email", d) + + def test_from_base64_with_schema_missing_required_field_raises(self) -> None: + s = base64.b64encode(json.dumps({"name": "Alice"}).encode()).decode() + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_base64(s, schema=UserSchema) + + +class io_dict_from_pickle_schema_test_case(unittest.TestCase): + def test_from_pickle_with_schema_valid(self) -> None: + s = base64.b64encode( + pickle.dumps({"name": "Alice", "age": 30}, protocol=2) + ).decode() + d = IODict.from_pickle(s, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + + def test_from_pickle_with_schema_strips_extra_fields(self) -> None: + s = base64.b64encode( + pickle.dumps( + {"name": "Alice", "age": 30, "email": "alice@example.com"}, protocol=2 + ) + ).decode() + d = IODict.from_pickle(s, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertNotIn("email", d) + + def test_from_pickle_with_schema_missing_required_field_raises(self) -> None: + s = base64.b64encode(pickle.dumps({"name": "Alice"}, protocol=2)).decode() + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_pickle(s, schema=UserSchema) + + +class io_dict_from_plist_schema_test_case(unittest.TestCase): + def test_from_plist_with_schema_valid(self) -> None: + s = plistlib.dumps({"name": "Alice", "age": 30}).decode() + d = IODict.from_plist(s, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + + def test_from_plist_with_schema_strips_extra_fields(self) -> None: + s = plistlib.dumps( + {"name": "Alice", "age": 30, "email": "alice@example.com"} + ).decode() + d = IODict.from_plist(s, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertNotIn("email", d) + + def test_from_plist_with_schema_missing_required_field_raises(self) -> None: + s = plistlib.dumps({"name": "Alice"}).decode() + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_plist(s, schema=UserSchema) + + +class io_dict_from_query_string_schema_test_case(unittest.TestCase): + def test_from_query_string_with_schema_valid(self) -> None: + # query strings carry all values as strings; pydantic coerces age to int + s = "name=Alice&age=30" + d = IODict.from_query_string(s, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertIsInstance(d["age"], int) + + def test_from_query_string_with_schema_strips_extra_fields(self) -> None: + s = "name=Alice&age=30&email=alice%40example.com" + d = IODict.from_query_string(s, schema=UserSchema) + self.assertEqual(d, {"name": "Alice", "age": 30}) + self.assertNotIn("email", d) + + def test_from_query_string_with_schema_missing_required_field_raises(self) -> None: + s = "name=Alice" # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + IODict.from_query_string(s, schema=UserSchema) + + +class io_dict_to_yaml_schema_test_case(unittest.TestCase): + def test_to_yaml_with_schema_valid(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_yaml(schema=UserSchema) + self.assertIsInstance(result, str) + self.assertIn("Alice", result) + self.assertIn("30", result) + + def test_to_yaml_with_schema_strips_extra_fields(self) -> None: + d = benedict({"name": "Alice", "age": 30, "email": "alice@example.com"}) + result = d.to_yaml(schema=UserSchema) + self.assertNotIn("email", result) + self.assertIn("Alice", result) + + def test_to_yaml_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"name": "Alice"}) # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_yaml(schema=UserSchema) + + +class io_dict_to_ini_schema_test_case(unittest.TestCase): + def test_to_ini_with_schema_valid(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_ini(schema=UserSchema) + self.assertIsInstance(result, str) + self.assertIn("Alice", result) + + def test_to_ini_with_schema_strips_extra_fields(self) -> None: + d = benedict({"name": "Alice", "age": 30, "email": "alice@example.com"}) + result = d.to_ini(schema=UserSchema) + self.assertNotIn("email", result) + self.assertIn("Alice", result) + + def test_to_ini_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"name": "Alice"}) # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_ini(schema=UserSchema) + + +class io_dict_to_toml_schema_test_case(unittest.TestCase): + def test_to_toml_with_schema_valid(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_toml(schema=UserSchema) + self.assertIsInstance(result, str) + self.assertIn("Alice", result) + + def test_to_toml_with_schema_strips_extra_fields(self) -> None: + d = benedict({"name": "Alice", "age": 30, "email": "alice@example.com"}) + result = d.to_toml(schema=UserSchema) + self.assertNotIn("email", result) + self.assertIn("Alice", result) + + def test_to_toml_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"name": "Alice"}) # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_toml(schema=UserSchema) + + +class io_dict_to_xml_schema_test_case(unittest.TestCase): + def test_to_xml_with_schema_valid(self) -> None: + d = benedict({"root": {"name": "Alice", "age": 30}}) + result = d.to_xml(schema=XMLWrappedSchema) + self.assertIsInstance(result, str) + self.assertIn("Alice", result) + self.assertIn("30", result) + + def test_to_xml_with_schema_strips_extra_fields(self) -> None: + d = benedict( + {"root": {"name": "Alice", "age": 30, "email": "alice@example.com"}} + ) + result = d.to_xml(schema=XMLWrappedSchema) + self.assertNotIn("email", result) + self.assertIn("Alice", result) + + def test_to_xml_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"root": {"name": "Alice"}}) # root.age required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_xml(schema=XMLWrappedSchema) + + +class io_dict_to_base64_schema_test_case(unittest.TestCase): + def test_to_base64_with_schema_valid(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_base64(schema=UserSchema) + decoded = IODict.from_base64(result) + self.assertEqual(dict(decoded), {"name": "Alice", "age": 30}) + + def test_to_base64_with_schema_strips_extra_fields(self) -> None: + d = benedict({"name": "Alice", "age": 30, "email": "alice@example.com"}) + result = d.to_base64(schema=UserSchema) + decoded = IODict.from_base64(result) + self.assertEqual(dict(decoded), {"name": "Alice", "age": 30}) + self.assertNotIn("email", decoded) + + def test_to_base64_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"name": "Alice"}) # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_base64(schema=UserSchema) + + +class io_dict_to_pickle_schema_test_case(unittest.TestCase): + def test_to_pickle_with_schema_valid(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_pickle(schema=UserSchema) + decoded = IODict.from_pickle(result) + self.assertEqual(dict(decoded), {"name": "Alice", "age": 30}) + + def test_to_pickle_with_schema_strips_extra_fields(self) -> None: + d = benedict({"name": "Alice", "age": 30, "email": "alice@example.com"}) + result = d.to_pickle(schema=UserSchema) + decoded = IODict.from_pickle(result) + self.assertEqual(dict(decoded), {"name": "Alice", "age": 30}) + self.assertNotIn("email", decoded) + + def test_to_pickle_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"name": "Alice"}) # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_pickle(schema=UserSchema) + + +class io_dict_to_plist_schema_test_case(unittest.TestCase): + def test_to_plist_with_schema_valid(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_plist(schema=UserSchema) + decoded = IODict.from_plist(result) + self.assertEqual(dict(decoded), {"name": "Alice", "age": 30}) + + def test_to_plist_with_schema_strips_extra_fields(self) -> None: + d = benedict({"name": "Alice", "age": 30, "email": "alice@example.com"}) + result = d.to_plist(schema=UserSchema) + decoded = IODict.from_plist(result) + self.assertEqual(dict(decoded), {"name": "Alice", "age": 30}) + self.assertNotIn("email", decoded) + + def test_to_plist_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"name": "Alice"}) # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_plist(schema=UserSchema) + + +class io_dict_to_query_string_schema_test_case(unittest.TestCase): + def test_to_query_string_with_schema_valid(self) -> None: + d = benedict({"name": "Alice", "age": 30}) + result = d.to_query_string(schema=UserSchema) + self.assertIsInstance(result, str) + self.assertIn("Alice", result) + + def test_to_query_string_with_schema_strips_extra_fields(self) -> None: + d = benedict({"name": "Alice", "age": 30, "email": "alice@example.com"}) + result = d.to_query_string(schema=UserSchema) + self.assertNotIn("email", result) + self.assertIn("Alice", result) + + def test_to_query_string_with_schema_missing_required_field_raises(self) -> None: + d = benedict({"name": "Alice"}) # 'age' required + with self.assertRaises((pydantic.ValidationError, ValueError)): + d.to_query_string(schema=UserSchema)