Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add private attributes support #1679

Merged
merged 26 commits into from
Oct 26, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5964240
Add private attributes support
Bobronium Jul 2, 2020
d783caf
Add more blank lines in example
Bobronium Jul 2, 2020
d4dabc4
Add changes file
Bobronium Jul 2, 2020
c182678
Merge branch 'master' into add-private-attributes
Bobronium Jul 3, 2020
1c7256a
Update docs/usage/models.md
Bobronium Sep 6, 2020
ef640ff
Merge branch 'master' into add-private-attributes
Bobronium Oct 25, 2020
3868ad3
fix after bad merge
Bobronium Oct 25, 2020
d60de4c
Add PrivateAttr, Config.underscore_attrs_are_private
Bobronium Oct 25, 2020
9096f63
remove unrelated change in utils.py
Bobronium Oct 25, 2020
90576d2
Merge branch 'master' into add-private-attributes
Bobronium Oct 25, 2020
a701b3e
add # noqa: C901 (ignore complexity) to __setattr__
Bobronium Oct 25, 2020
33e3f28
add annotation to Config.underscore_attrs_are_private
Bobronium Oct 25, 2020
dcfee02
use sunder names
Bobronium Oct 25, 2020
063ba48
mention underscore_attrs_are_private in model_config.md
Bobronium Oct 25, 2020
1333ffb
add comment about default factory
Bobronium Oct 25, 2020
788d24b
fix comment
Bobronium Oct 25, 2020
dedb465
fix comment
Bobronium Oct 25, 2020
6ea1a1f
clarify that both dunder and sunder names might be used
Bobronium Oct 25, 2020
08d6f5d
tweak docs and name
samuelcolvin Oct 26, 2020
60ebda2
_set_default_private_attributes -> _init_private_attributes
Bobronium Oct 26, 2020
2689d36
use new name _init_private_attributes
samuelcolvin Oct 26, 2020
afa3fb9
move tests
Bobronium Oct 26, 2020
47464ba
copy private attributes in BaseModel.copy()
Bobronium Oct 26, 2020
7fe179a
add test for default and default_factory used together
Bobronium Oct 26, 2020
2090ba4
fix linting
Bobronium Oct 26, 2020
104d600
more tests, default_factory kw only
samuelcolvin Oct 26, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/1679-MrMrRobat.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add private attributes support
47 changes: 47 additions & 0 deletions docs/examples/private_attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from datetime import datetime

from pydantic import BaseModel, Field

storage = {}


def get_new_id():
return max(storage) + 1 if storage else 0


class BaseStorageModel(BaseModel):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe the _processed_at example at the top of #655 is simpler and clearer?

__slots__ = ('_dirty', '_created')
_dirty: bool = True
_created: datetime = None

id: int = Field(default_factory=get_new_id)

def __setattr__(self, name, value):
super().__setattr__(name, value)
if name not in self.__private_attributes__:
self._dirty = True

def save(self):
if self._dirty:
if self.id not in storage:
self._created = datetime.utcnow()
storage[self.id] = self.dict()
self._dirty = False


class Model(BaseStorageModel):
foo: str
bar: int = 42


m = Model(foo='bar')
print(m._dirty, m._created, storage)

m.save()
print(m._dirty, m._created, storage)

m.foo = 'baz'
print(m._dirty)

m.save()
print(m._dirty, storage)
10 changes: 10 additions & 0 deletions docs/usage/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,16 @@ Where `Field` refers to the [field function](schema.md#field-customisation).
Moreover if you want to validate default values with `validate_all`,
*pydantic* will need to call the `default_factory`, which could lead to side effects!

## Private model attributes
If you need to use internal attribute excluded from model fields, you can declare such in `__slots__` of your class:
Bobronium marked this conversation as resolved.
Show resolved Hide resolved

```py
{!.tmp_examples/private_attributes.py!}
```
_(This script is complete, it should run "as is")_

Private attributes names must start with underscore to prevent conflicts with model fields.make

## Parsing data into a specified type

Pydantic includes a standalone utility function `parse_obj_as` that can be used to apply the parsing
Expand Down
3 changes: 1 addition & 2 deletions pydantic/class_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from .errors import ConfigError
from .typing import AnyCallable
from .utils import in_ipython
from .utils import ROOT_KEY, in_ipython


class Validator:
Expand Down Expand Up @@ -42,7 +42,6 @@ def __init__(
ValidatorListDict = Dict[str, List[Validator]]

_FUNCS: Set[str] = set()
ROOT_KEY = '__root__'
VALIDATOR_CONFIG_KEY = '__validator_config__'
ROOT_VALIDATOR_CONFIG_KEY = '__root_validator_config__'

Expand Down
12 changes: 2 additions & 10 deletions pydantic/fields.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import warnings
from collections.abc import Iterable as CollectionsIterable
from copy import deepcopy
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -36,7 +35,7 @@
is_new_type,
new_type_supertype,
)
from .utils import PyObjectStr, Representation, lenient_issubclass, sequence_like
from .utils import PyObjectStr, Representation, lenient_issubclass, sequence_like, smart_deepcopy
from .validators import constant_validator, dict_validator, find_validators, validate_json

Required: Any = Ellipsis
Expand Down Expand Up @@ -271,14 +270,7 @@ def __init__(
self.prepare()

def get_default(self) -> Any:
if self.default_factory is not None:
value = self.default_factory()
elif self.default is None:
# deepcopy is quite slow on None
value = None
else:
value = deepcopy(self.default)
return value
return smart_deepcopy(self.default) if self.default_factory is None else self.default_factory()

@classmethod
def infer(
Expand Down
83 changes: 54 additions & 29 deletions pydantic/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import sys
import warnings
from abc import ABCMeta
from copy import deepcopy
from enum import Enum
from functools import partial
from pathlib import Path
Expand All @@ -25,7 +24,7 @@
overload,
)

from .class_validators import ROOT_KEY, ValidatorGroup, extract_root_validators, extract_validators, inherit_validators
from .class_validators import ValidatorGroup, extract_root_validators, extract_validators, inherit_validators
from .error_wrappers import ErrorWrapper, ValidationError
from .errors import ConfigError, DictError, ExtraError, MissingError
from .fields import SHAPE_MAPPING, ModelField, Undefined
Expand All @@ -35,15 +34,19 @@
from .types import PyObject, StrBytes
from .typing import AnyCallable, ForwardRef, is_classvar, resolve_annotations, update_field_forward_refs
from .utils import (
ROOT_KEY,
ClassAttribute,
GetterDict,
Representation,
ValueItems,
generate_model_signature,
is_valid_field,
lenient_issubclass,
sequence_like,
smart_deepcopy,
unique_list,
validate_field_name,
validate_private_attributes,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -178,12 +181,6 @@ def prepare_config(config: Type[BaseConfig], cls_name: str) -> None:
config.case_sensitive = not config.case_insensitive # type: ignore


def is_valid_field(name: str) -> bool:
if not name.startswith('_'):
return True
return ROOT_KEY == name


def validate_custom_root_type(fields: Dict[str, ModelField]) -> None:
if len(fields) > 1:
raise ValueError('__root__ cannot be mixed with other fields')
Expand All @@ -205,15 +202,19 @@ def __new__(mcs, name, bases, namespace, **kwargs): # noqa C901
config = BaseConfig
validators: 'ValidatorListDict' = {}
fields_defaults: Dict[str, Any] = {}

pre_root_validators, post_root_validators = [], []
for base in reversed(bases):
if _is_base_model_class_defined and issubclass(base, BaseModel) and base != BaseModel:
fields.update(deepcopy(base.__fields__))
slots: Union[str, Tuple[str, ...]] = namespace.get('__slots__', ())
private_attributes: Dict[str, Any] = dict.fromkeys((slots,) if isinstance(slots, str) else slots, Undefined)
validate_private_attributes(private_attributes)

for base in reversed(bases) if _is_base_model_class_defined else ():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this just a cosmetic change? if so could you remove it to make this change easier to review, if not I think it belongs in another PR.

Copy link
Contributor Author

@Bobronium Bobronium Sep 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, unfortunately I don't remember. Should've put a comment here 🤦 . But from what I see, now it includes BaseModel and it didn't before. I'll try to remember the reason I did it this way and either put explaning comment, or change the line back.

if base is not BaseModel and issubclass(base, BaseModel):
fields.update(smart_deepcopy(base.__fields__))
config = inherit_config(base.__config__, config)
validators = inherit_validators(base.__validators__, validators)
pre_root_validators += base.__pre_root_validators__
post_root_validators += base.__post_root_validators__
private_attributes.update(base.__private_attributes__)

config = inherit_config(namespace.get('Config'), config)
validators = inherit_validators(extract_validators(namespace), validators)
Expand Down Expand Up @@ -260,7 +261,9 @@ def __new__(mcs, name, bases, namespace, **kwargs): # noqa C901
fields_defaults[ann_name] = inferred.default

for var_name, value in namespace.items():
if (
if var_name in private_attributes:
private_attributes[var_name] = value
elif (
var_name not in annotations
and is_valid_field(var_name)
and not isinstance(value, untouched_types)
Expand Down Expand Up @@ -303,7 +306,8 @@ def __new__(mcs, name, bases, namespace, **kwargs): # noqa C901
'__schema_cache__': {},
'__json_encoder__': staticmethod(json_encoder),
'__custom_root_type__': _custom_root_type,
**{n: v for n, v in namespace.items() if n not in fields},
'__private_attributes__': private_attributes,
**{n: v for n, v in namespace.items() if n not in fields | private_attributes.keys()},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

humm, does this create fields | private_attributes.keys() on every iteration?

Either way, might be clearer to define it beforehand.

}

cls = super().__new__(mcs, name, bases, new_namespace, **kwargs)
Expand All @@ -312,6 +316,9 @@ def __new__(mcs, name, bases, namespace, **kwargs): # noqa C901
return cls


object_setattr = object.__setattr__


class BaseModel(Representation, metaclass=ModelMetaclass):
if TYPE_CHECKING:
# populated by the metaclass, defined here to help IDEs only
Expand All @@ -326,6 +333,8 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
__schema_cache__: 'DictAny' = {}
__custom_root_type__: bool = False
__signature__: 'Signature'
__private_attributes__: Dict[str, Any]
__fields_set__: SetStr = set()

Config = BaseConfig
__slots__ = ('__dict__', '__fields_set__')
Expand All @@ -338,17 +347,18 @@ def __init__(__pydantic_self__, **data: Any) -> None:
Raises ValidationError if the input data cannot be parsed to form a valid model.
"""
# Uses something other than `self` the first arg to allow "self" as a settable attribute
if TYPE_CHECKING:
__pydantic_self__.__dict__: Dict[str, Any] = {}
__pydantic_self__.__fields_set__: 'SetStr' = set()
values, fields_set, validation_error = validate_model(__pydantic_self__.__class__, data)
if validation_error:
raise validation_error
object.__setattr__(__pydantic_self__, '__dict__', values)
object.__setattr__(__pydantic_self__, '__fields_set__', fields_set)
object_setattr(__pydantic_self__, '__dict__', values)
object_setattr(__pydantic_self__, '__fields_set__', fields_set)
__pydantic_self__._set_private_attributes(__pydantic_self__.__private_attributes__)

@no_type_check
def __setattr__(self, name, value):
if name in self.__private_attributes__:
return object_setattr(self, name, value)

if self.__config__.extra is not Extra.allow and name not in self.__fields__:
raise ValueError(f'"{self.__class__.__name__}" object has no field "{name}"')
elif not self.__config__.allow_mutation:
Expand All @@ -363,11 +373,23 @@ def __setattr__(self, name, value):
self.__fields_set__.add(name)

def __getstate__(self) -> 'DictAny':
return {'__dict__': self.__dict__, '__fields_set__': self.__fields_set__}
return {
'__dict__': self.__dict__,
'__fields_set__': self.__fields_set__,
'__private_attributes_values__': {k: getattr(self, k, Undefined) for k in self.__private_attributes__},
}

def __setstate__(self, state: 'DictAny') -> None:
object.__setattr__(self, '__dict__', state['__dict__'])
object.__setattr__(self, '__fields_set__', state['__fields_set__'])
object_setattr(self, '__dict__', state['__dict__'])
object_setattr(self, '__fields_set__', state['__fields_set__'])
self._set_private_attributes(state['__private_attributes_values__'], need_copy=False, check=False)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samuelcolvin, maybe we should use dict.get() here, so unpickling models dumped with older versions of pydantic would work?

Suggested change
self._set_private_attributes(state['__private_attributes_values__'], need_copy=False, check=False)
self._set_private_attributes(state.get('__private_attributes_values__', {}), need_copy=False, check=False)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes makes sense.


def _set_private_attributes(self, source: 'DictStrAny', need_copy: bool = True, check: bool = True) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the two kwargs are always used together, maybe they can be combined into check_copy or from_pickle=False?

This method might need a docstring too.

for name, value in source.items():
if value is not Undefined:
object_setattr(self, name, smart_deepcopy(value) if need_copy else value)
elif check and not hasattr(self, name):
raise AttributeError(f'private attribute "{name}" is unset')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe if check is True we should do the check first so the exception message is always the same?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or make it identical here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't get this one, could you explain?


def dict(
self,
Expand Down Expand Up @@ -506,8 +528,9 @@ def from_orm(cls: Type['Model'], obj: Any) -> 'Model':
values, fields_set, validation_error = validate_model(cls, obj)
if validation_error:
raise validation_error
object.__setattr__(m, '__dict__', values)
object.__setattr__(m, '__fields_set__', fields_set)
object_setattr(m, '__dict__', values)
object_setattr(m, '__fields_set__', fields_set)
m._set_private_attributes(cls.__private_attributes__)
return m

@classmethod
Expand All @@ -517,10 +540,11 @@ def construct(cls: Type['Model'], _fields_set: Optional['SetStr'] = None, **valu
Default values are respected, but no other validation is performed.
"""
m = cls.__new__(cls)
object.__setattr__(m, '__dict__', {**deepcopy(cls.__field_defaults__), **values})
object_setattr(m, '__dict__', {**smart_deepcopy(cls.__field_defaults__), **values})
if _fields_set is None:
_fields_set = set(values.keys())
object.__setattr__(m, '__fields_set__', _fields_set)
object_setattr(m, '__fields_set__', _fields_set)
m._set_private_attributes(cls.__private_attributes__)
return m

def copy(
Expand Down Expand Up @@ -548,12 +572,13 @@ def copy(
)

if deep:
v = deepcopy(v)
v = smart_deepcopy(v)

cls = self.__class__
m = cls.__new__(cls)
object.__setattr__(m, '__dict__', v)
object.__setattr__(m, '__fields_set__', self.__fields_set__.copy())
object_setattr(m, '__dict__', v)
object_setattr(m, '__fields_set__', self.__fields_set__.copy())
m._set_private_attributes(cls.__private_attributes__)
return m

@classmethod
Expand Down
3 changes: 1 addition & 2 deletions pydantic/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
)
from uuid import UUID

from .class_validators import ROOT_KEY
from .fields import (
SHAPE_FROZENSET,
SHAPE_ITERABLE,
Expand Down Expand Up @@ -56,7 +55,7 @@
constr,
)
from .typing import ForwardRef, Literal, is_callable_type, is_literal_type, literal_values
from .utils import get_model, lenient_issubclass, sequence_like
from .utils import ROOT_KEY, get_model, lenient_issubclass, sequence_like

if TYPE_CHECKING:
from .main import BaseModel # noqa: F401
Expand Down