Skip to content

Commit

Permalink
ORM mode: Add support for arbitrary class instances (#562)
Browse files Browse the repository at this point in the history
* Support ORM objects to 'parse_obj', replace #520

* switch to GetterDict and orm_mode

* tweaks

* update docs

* split tests and add @tiangolo's suggestion

* split tests and add @tiangolo's suggestion

* fix coverage
  • Loading branch information
samuelcolvin committed Jun 6, 2019
1 parent 6d5c48e commit 3dfae21
Show file tree
Hide file tree
Showing 9 changed files with 318 additions and 5 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ _build/
pydantic/*.c
pydantic/*.so
.auto-format
/sandbox/
1 change: 1 addition & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ v0.28 (unreleased)
* fix support for JSON Schema generation when using models with circular references in Python 3.7, #572 by @tiangolo
* support ``__post_init_post_parse__`` on dataclasses, #567 by @sevaho
* allow dumping dataclasses to JSON, #575 by @samuelcolvin and @DanielOberg
* ORM mode, #562 by @samuelcolvin
* fix ``pydantic.compiled`` on ipython, #573 by @dmontagu and @samuelcolvin

v0.27 (2019-05-30)
Expand Down
30 changes: 30 additions & 0 deletions docs/examples/orm_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import List
from sqlalchemy import Column, Integer, String
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.ext.declarative import declarative_base
from pydantic import BaseModel, constr

Base = declarative_base()

class CompanyOrm(Base):
__tablename__ = 'companies'
id = Column(Integer, primary_key=True, nullable=False)
public_key = Column(String(20), index=True, nullable=False, unique=True)
name = Column(String(63), unique=True)
domains = Column(ARRAY(String(255)))

class CompanyModel(BaseModel):
id: int
public_key: constr(max_length=20)
name: constr(max_length=63)
domains: List[constr(max_length=255)]

class Config:
orm_mode = True

co_orm = CompanyOrm(id=123, public_key='foobar', name='Testing', domains=['example.com', 'foobar.com'])
print(co_orm)
#> <__main__.CompanyOrm object at 0x7ff4bf918278>
co_model = CompanyModel.from_orm(co_orm)
print(co_model)
#> CompanyModel id=123 public_key='foobar' name='Testing' domains=['example.com', 'foobar.com']
35 changes: 35 additions & 0 deletions docs/examples/orm_mode_recursive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import List
from pydantic import BaseModel

class PetCls:
def __init__(self, *, name: str, species: str):
self.name = name
self.species = species

class PersonCls:
def __init__(self, *, name: str, age: float = None, pets: List[PetCls]):
self.name = name
self.age = age
self.pets = pets

class Pet(BaseModel):
name: str
species: str

class Config:
orm_mode = True

class Person(BaseModel):
name: str
age: float = None
pets: List[Pet]

class Config:
orm_mode = True

bones = PetCls(name='Bones', species='dog')
orion = PetCls(name='Orion', species='cat')
anna = PersonCls(name='Anna', age=20, pets=[bones, orion])
anna_model = Person.from_orm(anna)
print(anna_model)
#> Person name='Anna' pets=[<Pet name='Bones' species='dog'>, <Pet name='Orion' species='cat'>] age=20.0
26 changes: 26 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,31 @@ and pydantic versions).

(This script is complete, it should run "as is")

.. _orm_mode:

ORM Mode (aka Arbitrary Class Instances)
........................................

Pydantic models can be created from arbitrary class instances to support models that map to ORM objects.

To do this:
1. The :ref:`Config <config>` property ``orm_mode`` must be set to ``True``.
2. The special constructor ``from_orm`` must be used to create the model instance.

The example here uses SQLAlchemy but the same approach should work for any ORM.

.. literalinclude:: examples/orm_mode.py

(This script is complete, it should run "as is")

ORM instances will be parsed with ``from_orm`` recursively as well as at the top level.

Here a vanilla class is used to demonstrate the principle, but any ORM could be used instead.

.. literalinclude:: examples/orm_mode_recursive.py

(This script is complete, it should run "as is")

.. _schema:

Schema Creation
Expand Down Expand Up @@ -642,6 +667,7 @@ Options:
value is instance of that type). If False - RuntimeError will be raised on model declaration (default: ``False``)
:json_encoders: customise the way types are encoded to json, see :ref:`JSON Serialisation <json_dump>` for more
details.
:orm_mode: allows usage of :ref:`ORM mode <orm_mode>`

.. warning::

Expand Down
23 changes: 21 additions & 2 deletions pydantic/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
AnyCallable,
AnyType,
ForwardRef,
GetterDict,
change_exception,
is_classvar,
resolve_annotations,
Expand Down Expand Up @@ -92,6 +93,7 @@ class BaseConfig:
error_msg_templates: Dict[str, str] = {}
arbitrary_types_allowed = False
json_encoders: Dict[AnyType, AnyCallable] = {}
orm_mode: bool = False

@classmethod
def get_field_schema(cls, name: str) -> Dict[str, str]:
Expand Down Expand Up @@ -362,6 +364,17 @@ def parse_file(
obj = load_file(path, proto=proto, content_type=content_type, encoding=encoding, allow_pickle=allow_pickle)
return cls.parse_obj(obj)

@classmethod
def from_orm(cls: Type['Model'], obj: Any) -> 'Model':
if not cls.__config__.orm_mode:
raise ConfigError('You must have the config attribute orm_mode=True to use from_orm')
obj = cls._decompose_class(obj)
m = cls.__new__(cls)
values, fields_set, _ = validate_model(m, obj)
object.__setattr__(m, '__values__', values)
object.__setattr__(m, '__fields_set__', fields_set)
return m

@classmethod
def construct(cls: Type['Model'], values: 'DictAny', fields_set: 'SetStr') -> 'Model':
"""
Expand Down Expand Up @@ -430,14 +443,20 @@ def __get_validators__(cls) -> 'CallableGenerator':
yield cls.validate

@classmethod
def validate(cls: Type['Model'], value: Union['DictStrAny', 'Model']) -> 'Model':
def validate(cls: Type['Model'], value: Any) -> 'Model':
if isinstance(value, dict):
return cls(**value)
elif isinstance(value, cls):
return value.copy()
elif cls.__config__.orm_mode:
return cls.from_orm(value)
else:
with change_exception(DictError, TypeError, ValueError):
return cls(**dict(value)) # type: ignore
return cls(**dict(value))

@classmethod
def _decompose_class(cls: Type['Model'], obj: Any) -> GetterDict:
return GetterDict(obj)

@classmethod
def _get_value(cls, v: Any, by_alias: bool, skip_defaults: bool) -> Any:
Expand Down
37 changes: 35 additions & 2 deletions pydantic/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,21 @@
from functools import lru_cache
from importlib import import_module
from textwrap import dedent
from typing import _eval_type # type: ignore
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Generator, List, Optional, Pattern, Tuple, Type, Union
from typing import ( # type: ignore
TYPE_CHECKING,
Any,
ClassVar,
Dict,
Generator,
List,
Optional,
Pattern,
Set,
Tuple,
Type,
Union,
_eval_type,
)

import pydantic

Expand Down Expand Up @@ -297,3 +310,23 @@ def almost_equal_floats(value_1: float, value_2: float, *, delta: float = 1e-8)
Return True if two floats are almost equal
"""
return abs(value_1 - value_2) <= delta


class GetterDict:
"""
Hack to make object's smell just enough like dicts for validate_model.
"""

__slots__ = ('_obj',)

def __init__(self, obj: Any):
self._obj = obj

def get(self, item: Any, default: Any) -> Any:
return getattr(self._obj, item, default)

def keys(self) -> Set[Any]:
"""
We don't want to get any other attributes of obj if the model didn't explicitly ask for them
"""
return set()
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def extra(self):
version = SourceFileLoader('version', 'pydantic/version.py').load_module()

ext_modules = None
if 'clean' not in sys.argv and 'SKIP_CYTHON' not in os.environ:
if not any(arg in sys.argv for arg in ['clean', 'check']) and 'SKIP_CYTHON' not in os.environ:
try:
from Cython.Build import cythonize
except ImportError:
Expand Down

0 comments on commit 3dfae21

Please sign in to comment.