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
Add private attributes support #1679
Conversation
Codecov Report
@@ Coverage Diff @@
## master #1679 +/- ##
=======================================
Coverage 99.90% 99.90%
=======================================
Files 21 21
Lines 4035 4051 +16
Branches 805 807 +2
=======================================
+ Hits 4031 4047 +16
Misses 3 3
Partials 1 1
Continue to review full report at Codecov.
|
@samuelcolvin, seems like |
I quite like this way of doing it |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sorry it's been so long, I've been really busy.
This looks amazing, but might need to be split into multiple PRs.
pydantic/utils.py
Outdated
bool, | ||
bytes, | ||
type, | ||
type(None), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
type(None), | |
NoneType, |
(imported from ./typing.py
)
pydantic/utils.py
Outdated
) | ||
|
||
ROOT_KEY = '__root__' | ||
IMMUTABLE_NON_COLLECTIONS_TYPES: AbstractSet[Type[Any]] = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMMUTABLE_NON_COLLECTIONS_TYPES: AbstractSet[Type[Any]] = { | |
IMMUTABLE_NON_COLLECTIONS_TYPES: Set[Type[Any]] = { |
Any reason not to use normal Set
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
might be worth adding a comment explaining this? Also maybe IMMUTABLE_TYPES
would be a better name?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
might be worth adding a comment explaining this?
Sure, I commented usage of this constant. Should I describe it alone here?
Also maybe
IMMUTABLE_TYPES
would be a better name?
Well, tuple()
is immutable type and yet could not be included in this set, because it can contain mutable types, so I thought it's worth clarifying
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok
pydantic/utils.py
Outdated
@@ -94,7 +112,7 @@ def validate_field_name(bases: List[Type['BaseModel']], field_name: str) -> None | |||
Ensure that the field's name does not shadow an existing attribute of the model. | |||
""" | |||
for base in bases: | |||
if getattr(base, field_name, None): | |||
if hasattr(base, field_name): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please remove this change, it's unrelated.
pydantic/utils.py
Outdated
Obj = TypeVar('Obj') | ||
|
||
|
||
def smart_deepcopy(obj: Obj) -> Obj: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this looks very cool, but are we 100% sure it always works? Or just works in all the cases we know of?
Also, this is great, but might be best to implement it as a separate PR, it's not really related to the core change here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, separate PR is more sutable for this change.
Or just works in all the cases we know of?
Good catch. I think implementing faster behaviour only for built-in collections and immutable types is a way to go.
pydantic/main.py
Outdated
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 (): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
pydantic/main.py
Outdated
@@ -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()}, |
There was a problem hiding this comment.
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.
pydantic/main.py
Outdated
object_setattr(self, '__fields_set__', state['__fields_set__']) | ||
self._set_private_attributes(state['__private_attributes_values__'], need_copy=False, check=False) | ||
|
||
def _set_private_attributes(self, source: 'DictStrAny', need_copy: bool = True, check: bool = True) -> None: |
There was a problem hiding this comment.
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.
pydantic/main.py
Outdated
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') |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
docs/examples/private_attributes.py
Outdated
return max(storage) + 1 if storage else 0 | ||
|
||
|
||
class BaseStorageModel(BaseModel): |
There was a problem hiding this comment.
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?
Co-authored-by: Samuel Colvin <samcolvin@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@samuelcolvin, thanks for review, I answered some comments and think I'll be able to split PR on the next weekend.
pydantic/utils.py
Outdated
) | ||
|
||
ROOT_KEY = '__root__' | ||
IMMUTABLE_NON_COLLECTIONS_TYPES: AbstractSet[Type[Any]] = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
might be worth adding a comment explaining this?
Sure, I commented usage of this constant. Should I describe it alone here?
Also maybe
IMMUTABLE_TYPES
would be a better name?
Well, tuple()
is immutable type and yet could not be included in this set, because it can contain mutable types, so I thought it's worth clarifying
pydantic/utils.py
Outdated
Obj = TypeVar('Obj') | ||
|
||
|
||
def smart_deepcopy(obj: Obj) -> Obj: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, separate PR is more sutable for this change.
Or just works in all the cases we know of?
Good catch. I think implementing faster behaviour only for built-in collections and immutable types is a way to go.
pydantic/main.py
Outdated
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) |
There was a problem hiding this comment.
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?
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes makes sense.
pydantic/main.py
Outdated
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 (): |
There was a problem hiding this comment.
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.
* add smart_deepcopy * uncomment tuple in BUILTIN_COLLECTIONS, fix doc a bit * Fix grammar Co-authored-by: PrettyWood <em.jolibois@gmail.com> * replace map() usage with generator comprehension, fix comment Co-authored-by: PrettyWood <em.jolibois@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Most things look good here, met me know if I've missed any questions.
pydantic/main.py
Outdated
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes makes sense.
pydantic/utils.py
Outdated
) | ||
|
||
ROOT_KEY = '__root__' | ||
IMMUTABLE_NON_COLLECTIONS_TYPES: AbstractSet[Type[Any]] = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok
I was procrastinating about this PR recently, as I thought that it might be worth adding support of For example, this will give error about from pydantic import BaseModel
from datetime import datetime
class TestExtra(BaseModel):
a: int
__slots__ = ('_processed_at',)
_processed_at: datetime
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._processed_at = datetime.utcnow()
TestExtra(**{"a": 1}) I would prefer having something like this: ( from pydantic import BaseModel, PrivateField
from datetime import datetime
class TestExtra(BaseModel):
a: int
_processed_at: datetime = PrivateField(default_factory=datetime) # or Field(private=True, ...
TestExtra(**{"a": 1}) |
automatically generating |
What do you think about |
# Conflicts: # pydantic/main.py # pydantic/utils.py # tests/test_utils.py
I think probably easiest if it's a regular class. I think we should keep it separate from |
# Conflicts: # pydantic/main.py # pydantic/schema.py # tests/test_construction.py
(see comment in PR)
|
||
@no_type_check | ||
def __setattr__(self, name, value): | ||
def __setattr__(self, name, value): # noqa: C901 (ignore complexity) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
had to set # noqa: C901 (ignore complexity)
here because of today's changes in #1972. Is this the way to go, or something should be moved outside of this method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i think this is fine.
@MrMrRobat this is looking good, do you think it will be ready to merge today? I want to get v1.7 released, but I'm happy wait until tomorrow if we can include this? |
Yeah, if don't have any comments or suggestions, it's ready :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looking good, just a few things to clarify.
pydantic/main.py
Outdated
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_default_private_attributes(__pydantic_self__.__private_attributes__) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why is this required?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what do you mean exactly?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we avoid this line since this is the critical path in terms of performance?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It sets defaults of __private_attributes__
, without this line default
not default_factoru
wouldn't work.
What's critical here in terms of performance? Function call itself or executing code of the function?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I guess we need it.
Initialising/validating models is the critical path, I don't want to slow that down unless we absolutely have to.
__private_attr__: str = 'private attr value' | ||
|
||
class Config: | ||
underscore_attrs_are_private = True |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I this needs documenting in model_config.md
. Also we need to be clear exactly what form names need to be in.
It looks from this like if you use PrivateAttr
you can use _whatever
, but with underscore_attrs_are_private
you have to use __whatever__
, but reading the code, I think that's not the case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
actually you've written it clear in the copy, but still better to use the same format in both code examples.
Is there anything special about __whatever__
or is it just that it starts with an underscore, like _whatever
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since I think according to some PEP we're not supposed to use dunder methods, unless they're official to python, maybe leave them out of the example and just mention them in the text?
Co-authored-by: Samuel Colvin <samcolvin@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is looking amazing, thank you @MrMrRobat.
I've tried it myself and i think it'll be extremely useful.
I have one small proposed change in name, but only other question is on model.copy()
which currently doesn't copy private attributes, but instead initialises from the default or default factory. I think I would expect copy()
to (deep?)copy the private attributes, a bit like __setstate__
.
What do you think?
Co-authored-by: Samuel Colvin <samcolvin@gmail.com>
you need to reference the new name, sorry I should have made that clear in my change proposal. Will you change it or will I? |
Yeah, you're right. Working on it. |
Done |
awesome, thank you. I'm really looking forward to using this. |
Thanks `PrivateAttr` and pydantic/pydantic#1679.
Question on this in #4518. |
Change Summary
Private attributes declared as regular fields, but always start with underscore and
PrivateAttr
is used instead ofField
. Upon class creation they added in__slots__
andModel.__private_attributes__
for faster lookups and support for default valuesDefault values declared just like fields:
_attr[: annotation] = default_value
or_attr = PrivateAttr(default_value)
Example from docs
Other changes
Replaced
copy.deepcopy()
usage with newpydantic.utils.smart_deepcopy()
for faster immutable values handlingRelated issue number
Closes #655
Checklist
changes/<pull request or issue id>-<github username>.md
file added describing change(see changes/README.md for details)