diff --git a/changes/934-dmontagu.md b/changes/934-dmontagu.md new file mode 100644 index 0000000..6b35cc4 --- /dev/null +++ b/changes/934-dmontagu.md @@ -0,0 +1 @@ +Add `parse_obj_as` and `parse_file_as` functions for ad-hoc parsing of data into arbitrary pydantic-compatible types. \ No newline at end of file diff --git a/docs/examples/parse_obj_as.py b/docs/examples/parse_obj_as.py new file mode 100644 index 0000000..ce91eb1 --- /dev/null +++ b/docs/examples/parse_obj_as.py @@ -0,0 +1,14 @@ +from typing import List + +from pydantic import BaseModel, parse_obj_as + +class Item(BaseModel): + id: int + name: str + +# `item_data` could come from an API call, eg., via something like: +# item_data = requests.get('https://my-api.com/items').json() +item_data = [{'id': 1, 'name': 'My Item'}] + +items = parse_obj_as(List[Item], item_data) +print(items) diff --git a/docs/usage/models.md b/docs/usage/models.md index 63aa95d..fa905fa 100644 --- a/docs/usage/models.md +++ b/docs/usage/models.md @@ -424,6 +424,25 @@ Where `Field` refers to the [field function](schema.md#field-customisation). Here `a`, `b` and `c` are all required. However, use of the ellipses in `b` will not work well with [mypy](mypy.md), and as of **v1.0** should be avoided in most cases. +## Parsing data into a specified type + +Pydantic includes a standalone utility function `parse_obj_as` that can be used to apply the parsing +logic used to populate pydantic models in a more ad-hoc way. This function behaves similarly to +`BaseModel.parse_obj`, but works with arbitrary pydantic-compatible types. + +This is especially useful when you want to parse results into a type that is not a direct subclass of `BaseModel`. +For example: + +```py +{!.tmp_examples/parse_obj_as.py!} +``` +_(This script is complete, it should run "as is")_ + +This function is capable of parsing data into any of the types pydantic can handle as fields of a `BaseModel`. + +Pydantic also includes a similar standalone function called `parse_file_as`, +which is analogous to `BaseModel.parse_file`. + ## Data Conversion *pydantic* may cast input data to force it to conform to model field types, diff --git a/pydantic/__init__.py b/pydantic/__init__.py index d031646..81840b3 100644 --- a/pydantic/__init__.py +++ b/pydantic/__init__.py @@ -8,5 +8,6 @@ from .fields import Field, Required, Schema from .main import * from .networks import * from .parse import Protocol +from .tools import * from .types import * from .version import VERSION diff --git a/pydantic/tools.py b/pydantic/tools.py new file mode 100644 index 0000000..1bc4ad9 --- /dev/null +++ b/pydantic/tools.py @@ -0,0 +1,48 @@ +from functools import lru_cache +from pathlib import Path +from typing import Any, Callable, Optional, Type, TypeVar, Union + +from pydantic.parse import Protocol, load_file + +from .typing import display_as_type + +__all__ = ('parse_file_as', 'parse_obj_as') + +NameFactory = Union[str, Callable[[Type[Any]], str]] + + +def _generate_parsing_type_name(type_: Any) -> str: + return f'ParsingModel[{display_as_type(type_)}]' + + +@lru_cache(maxsize=2048) +def _get_parsing_type(type_: Any, *, type_name: Optional[NameFactory] = None) -> Any: + from pydantic.main import create_model + + if type_name is None: + type_name = _generate_parsing_type_name + if not isinstance(type_name, str): + type_name = type_name(type_) + return create_model(type_name, __root__=(type_, ...)) + + +T = TypeVar('T') + + +def parse_obj_as(type_: Type[T], obj: Any, *, type_name: Optional[NameFactory] = None) -> T: + model_type = _get_parsing_type(type_, type_name=type_name) + return model_type(__root__=obj).__root__ + + +def parse_file_as( + type_: Type[T], + path: Union[str, Path], + *, + content_type: str = None, + encoding: str = 'utf8', + proto: Protocol = None, + allow_pickle: bool = False, + type_name: Optional[NameFactory] = None, +) -> T: + obj = load_file(path, proto=proto, content_type=content_type, encoding=encoding, allow_pickle=allow_pickle) + return parse_obj_as(type_, obj, type_name=type_name) diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..df4a4db --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,78 @@ +from typing import Dict, List, Mapping + +import pytest + +from pydantic import BaseModel, ValidationError +from pydantic.dataclasses import dataclass +from pydantic.tools import parse_file_as, parse_obj_as + + +@pytest.mark.parametrize('obj,type_,parsed', [('1', int, 1), (['1'], List[int], [1])]) +def test_parse_obj(obj, type_, parsed): + assert parse_obj_as(type_, obj) == parsed + + +def test_parse_obj_as_model(): + class Model(BaseModel): + x: int + y: bool + z: str + + model_inputs = {'x': '1', 'y': 'true', 'z': 'abc'} + assert parse_obj_as(Model, model_inputs) == Model(**model_inputs) + + +def test_parse_obj_preserves_subclasses(): + class ModelA(BaseModel): + a: Mapping[int, str] + + class ModelB(ModelA): + b: int + + model_b = ModelB(a={1: 'f'}, b=2) + + parsed = parse_obj_as(List[ModelA], [model_b]) + assert parsed == [model_b] + + +def test_parse_obj_fails(): + with pytest.raises(ValidationError) as exc_info: + parse_obj_as(int, 'a') + assert exc_info.value.errors() == [ + {'loc': ('__root__',), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'} + ] + assert exc_info.value.model.__name__ == 'ParsingModel[int]' + + +def test_parsing_model_naming(): + with pytest.raises(ValidationError) as exc_info: + parse_obj_as(int, 'a') + assert str(exc_info.value).split('\n')[0] == '1 validation error for ParsingModel[int]' + + with pytest.raises(ValidationError) as exc_info: + parse_obj_as(int, 'a', type_name='ParsingModel') + assert str(exc_info.value).split('\n')[0] == '1 validation error for ParsingModel' + + with pytest.raises(ValidationError) as exc_info: + parse_obj_as(int, 'a', type_name=lambda type_: type_.__name__) + assert str(exc_info.value).split('\n')[0] == '1 validation error for int' + + +def test_parse_as_dataclass(): + @dataclass + class PydanticDataclass: + x: int + + inputs = {'x': '1'} + assert parse_obj_as(PydanticDataclass, inputs) == PydanticDataclass(1) + + +def test_parse_mapping_as(): + inputs = {'1': '2'} + assert parse_obj_as(Dict[int, int], inputs) == {1: 2} + + +def test_parse_file_as(tmp_path): + p = tmp_path / 'test.json' + p.write_text('{"1": "2"}') + assert parse_file_as(Dict[int, int], p) == {1: 2}