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

feat: add computed fields #2625

Closed
wants to merge 28 commits into from
Closed

Conversation

PrettyWood
Copy link
Member

@PrettyWood PrettyWood commented Apr 2, 2021

Change Summary

I'm waiting for feedback on the API and doc 🙏

property-like syntax

from functools import cached_property
from random import randint

from pydantic import BaseModel, computed_field


class Square(BaseModel, underscore_attrs_are_private=True):
    side: float
    _private_attr: int = 1

    @computed_field(title='The area')
    @property
    def area(self) -> float:
        return self.side ** 2

    @area.setter
    def area(self, area: float) -> None:
        self.side = area ** .5

    @area.deleter
    def area(self) -> None:
        self.side = 0

    @computed_field(alias='The random number')
    @cached_property
    def random_n(self) -> int:
        return randint(0, 1_000)

    @property
    def public_attr(self) -> int:
        return self._private_attr + 1

    @public_attr.setter
    def public_attr(self, v: int) -> None:
        self._private_attr = v - 1


sq = Square(side=10)
assert sq._private_attr == 1
assert sq.public_attr == 2
sq.public_attr = 5
assert sq._private_attr == 4
assert sq.public_attr == 5
the_random_n = sq.random_n
assert sq.dict(by_alias=True) == {'side': 10, 'area': 100, 'The random number': the_random_n}
sq.area = 49
assert sq.dict() == {'side': 7, 'area': 49, 'random_n': the_random_n}
del sq.area
assert sq.dict() == {'side': 0, 'area': 0, 'random_n': the_random_n}
assert sq.dict(exclude={'random_n'}) == {'side': 0, 'area': 0}

ComputedField syntax

from pydantic import BaseModel, ComputedField, ValidationError, validator


class User(BaseModel, validate_assignment=True):
    first_name: str
    surname: str
    full_name: str = ComputedField(lambda self: f'{self.first_name} {self.surname}')

    @validator('full_name')
    def should_not_have_pika(cls, v: str) -> str:
        if 'pika' in v.lower():
            raise ValueError('We love pika but not that much!')
        return v


u = User(first_name='John', surname='Smith')
assert u.dict() == {'first_name': 'John', 'surname': 'Smith', 'full_name': 'John Smith'}
u.first_name = 'Mike'
assert u.dict()['full_name'] == 'Mike Smith'

try:
    User(first_name='Pika', surname='Chu')
    assert False
except ValidationError:
    # pydantic.error_wrappers.ValidationError: 1 validation error for User
    # full_name
    #   We love pika but not that much! (type=value_error)
    assert True

See the tests for more examples

Related issue number

closes #935
closes #1241
closes #2313

Checklist

  • Unit tests for the changes exist
  • Tests pass on CI and coverage remains at 100%
  • Documentation reflects the changes where applicable
  • changes/<pull request or issue id>-<github username>.md file added describing change
    (see changes/README.md for details)

@PrettyWood PrettyWood force-pushed the field-property branch 2 times, most recently from b4ed70b to 11d6ac9 Compare April 2, 2021 22:50
@PrettyWood PrettyWood mentioned this pull request Apr 2, 2021
@PrettyWood PrettyWood changed the title feat: add computed fields (only getters for now) feat: add computed fields Apr 2, 2021
@PrettyWood PrettyWood force-pushed the field-property branch 7 times, most recently from 80363ea to 54e23c5 Compare April 3, 2021 14:28
@PrettyWood PrettyWood marked this pull request as ready for review April 3, 2021 14:28
@PrettyWood PrettyWood force-pushed the field-property branch 2 times, most recently from 7d8521c to 83a3916 Compare April 5, 2021 08:41
@PrettyWood
Copy link
Member Author

Writing only @computed_field instead of @field with @property is annoying because of mypy and IDE. Writing

If TYPE_CHECKING:
    computed_field = property

won't work with kwargs like alias and stuff.
And it won't work fine with property-like descriptors

This is why I chose to keep the combo @field + @property, which is explicit, just a few characters longer and well supported without any effort. The only issue is that decorating property itself is not yet handled by mypy but it's a known issue that may be solved in the near future.

If we want to change the API and need to work on a better mypy support fine but I won't do it myself sorry: I have not the will nor the energy to work on the mypy plugin since it's a lot of work for IMHO not a big gain.

@PrettyWood PrettyWood force-pushed the field-property branch 2 times, most recently from 9926afb to 3f775d1 Compare April 6, 2021 19:40
@yicone
Copy link

yicone commented May 31, 2022

When reference a property with @comuted_field annotation, the type hint of the reference is gone.

I'm not sure if the bug comes from this branch or the Python extension of VS Code currently.

E.g.:

@property
def A(self) -> int:
	print(self.B)  # actual is "(method) B: Any", expect is "(property) B: List[float]"
	return 1

@computed_field
@property
def B(self) -> List[float]:
	return []

Editor version info:

Visual Stuido Code: 1.67.2
with Python extension : v2022.6.2

Pydantic version info:

         pydantic version: 1.9.0
        pydantic compiled: False
             install path: /Users/Tr/Workspace/yicone/pydantic/pydantic
           python version: 3.9.12 (main, Mar 26 2022, 15:51:15)  [Clang 13.1.6 (clang-1316.0.21.2)]
                 platform: macOS-12.2-x86_64-i386-64bit
 optional deps. installed: ['typing-extensions']

@david-saeger
Copy link

any reason there hasnt been movement on this in a while? are we ever going to get it?

@Bobronium
Copy link
Contributor

Bobronium commented Jun 8, 2022

@yicone, can reproduce. Also can fix by replacing Any to ~T in annotation for func argument and return annotation of computed_field.

Copy link
Contributor

@Bobronium Bobronium left a comment

Choose a reason for hiding this comment

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

Fixes issue described in #2625 (comment)

Tested on VSCode, please also confirm that there's no regression in PyCharm.

image

image


@overload
def computed_field(
func: Any,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
func: Any,
func: T,

exclude: Optional['ExcludeInclude'] = None,
include: Optional['ExcludeInclude'] = None,
repr: bool = True,
) -> Any:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
) -> Any:
) -> T:



def computed_field(
func: Optional[Any] = None,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
func: Optional[Any] = None,
func: Optional[T] = None,

exclude: Optional['ExcludeInclude'] = None,
include: Optional['ExcludeInclude'] = None,
repr: bool = True,
) -> 'Union[Callable[[Any], ComputedField], ComputedField]':
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
) -> 'Union[Callable[[Any], ComputedField], ComputedField]':
) -> 'Union[Callable[[T], ComputedField], ComputedField]':

@Bobronium
Copy link
Contributor

Bobronium commented Jun 8, 2022

@pawelswiecki I'd suggest not to dig down into the rabbit hole when it comes to the features that can be implemented after the release. I think changes should be gradual and easy to make. It's possible to add support for @abstractmethod after releasing this. Removing such support is not as trivial though, since it would introduce breaking changes.

Let's focus on things that can't be easily changed/fixed after release.

As for your example, let me suggest this (click here):
from enum import Enum
from typing_extensions import Self

from pydantic import BaseModel, Field

class EventType(Enum):
    JOB_STARTED =  "job_started"
    JOB_FINISHED =  "job_finished"

class BaseEvent(BaseModel):
    id: str
    event_type: EventType

    def __init_subclass__(cls, **kwargs) -> None:
        return super().__init_subclass__(**kwargs)
        if (event_type_field := cls.__fields__.get('event_type')) is None or not event_type_field.field_info.const:
            raise NotImplementedError('You must define event_type for concrete event')

    @classmethod
    def create(cls, **data) -> Self:
        id_ = "generated id"
        return cls(id=id_, **data)

class JobStartedEvent(BaseEvent):
    event_type = Field(EventType.JOB_STARTED, const=True)

class JobFinishedEvent(BaseEvent):
    event_type = Field(EventType.JOB_FINISHED, const=True)


print(JobFinishedEvent.create().event_type)  # EventType.JOB_FINISHED


class InvalidEvent(BaseEvent):  ...  # NotImplementedError: You must define event_type for concrete event

@Bobronium
Copy link
Contributor

Bobronium commented Jun 8, 2022

To summarize, things to do before merging:

Is there something else I'm missing?

sorry for so many comments

@PrettyWood
Copy link
Member Author

@Bobronium thanks for the recap. I'll work on it this weekend. Sorry for being inactive, just a lot on my plate with both professional and personal matters

@Bobronium
Copy link
Contributor

Bobronium commented Jun 9, 2022

@prettywoo, can relate. Please take care!

Also, feel free to ask for any help if needed.

@caniko
Copy link
Contributor

caniko commented Jun 10, 2022

I am so excited for this feature to be implemented.

Is there something else I'm missing?

Yes @Bobronium, things to consider:

  • Testing with frozen=true
  • Validation feature, and testing
  • Testing that combines classmethod with computed_property:
    • Combined with property
    • Combined with cached_property
  • Method for and testing flushing cache of cached_property based computed_fields of both object and class

Thank you all so much for your hard work, it really does make my life much better at the very least. I can also help!

@caniko
Copy link
Contributor

caniko commented Jul 29, 2022

Suggestion:

I don't disagree with this, and think it makes most sense:

...
    @computed_field
    @cached_property
    def hard_compute():
        <very hard problem>
...

However, I suggest that we also introduce shorthand notation for property and cached_property:

    @cached_computed_field_property
    ...

And:

    @computed_field_property
    ...

This is nice when computed_fields are used frequently in a package, like my case. I don't want to end up in decorator-hell. Hope this makes sense.

@caniko
Copy link
Contributor

caniko commented Jul 29, 2022

Feature suggestion:
Can we make computed_fields work with numba.jit decorator where property and cached_property is optional?

I realize that this is out of scope for this feature, but perhaps use the underlying backend for jit? Like computed -> computed_field. Maybe this would fall under computed.

@computed(field_a, field_b, ...)
@jit
def some_func(field_a, field_b, ...):
    ...

@bjmc
Copy link
Contributor

bjmc commented Jul 29, 2022

cached_computed_field_property is rather long.

What about something like?

@computed_field(cache=True)
def calculate(self):
    pass

@caniko
Copy link
Contributor

caniko commented Jul 29, 2022

@bjmc, that is also an option. Where property is the default; however, it would interfere with the default behavior:

...
    @computed_field
    @property
    def hard_compute():
        <very hard problem>
...

Hence, my original suggestion.

@samuelcolvin samuelcolvin added deferred deferred until future release or until something else gets done and removed awaiting author revision labels Aug 4, 2022
@samuelcolvin
Copy link
Member

This won't be added for v1.10. I don't know if we can use some of this logic in V2 or whether we'll need a new implementation.

@pawelswiecki
Copy link

pawelswiecki commented Aug 4, 2022

@Bobronium Thank you for your suggestion, with the working code. Looking good, much appreciated!

@PrettyWood
Copy link
Member Author

PrettyWood commented Aug 10, 2022

For those waiting for this feature, it will be 100% added in v2!
Even though we may change the implementation, we got a lot of precious feedbacks (thanks everyone ❤️) on the desired APIs: a "property-like" one and a more "type annotation" one.
So we will most likely have these examples work the same 👍

EDIT: I'll make sure #2625 (comment) and #2625 (comment) are checked 😉

@Bobronium
Copy link
Contributor

Bobronium commented Aug 11, 2022

Wow. Looks like pylance (pyright) supports custom defined properties through simple descriptor typing.

I've managed to create a computed_field type that is interpreted by VSCode as a property (borrowed code for generic property here: python/typing#985 (comment)):

from __future__ import annotations
from random import randint
from typing import Any, Callable, Generic, NoReturn, TypeVar, overload



_G1 = TypeVar("_G1")
_G2 = TypeVar("_G2")
_S1 = TypeVar("_S1")
_S2 = TypeVar("_S2")


class computed_field(Generic[_G1, _S1]):
    fget: Callable[[Any], _G1] | None
    fset: Callable[[Any, _S1], None] | None
    fdel: Callable[[Any], None] | None

    @overload
    def __new__(
        cls,
        fget: Callable[[Any], _G2],
        fset: None = ...,
        fdel: Callable[[Any], None] | None = ...,
        doc: str | None = ...,
    ) -> computed_field[_G2, NoReturn]:
        ...

    @overload
    def __new__(
        cls,
        fget: Callable[[Any], _G2],
        fset: Callable[[Any, _S2], None],
        fdel: Callable[[Any], None] | None = ...,
        doc: str | None = ...,
    ) -> computed_field[_G2, _S2]:
        ...

    @overload
    def __new__(
        cls,
        fget: None = ...,
        fset: None = ...,
        fdel: Callable[[Any], None] | None = ...,
        alias: str | None = ...,
        title: str | None = ...,
        description: str | None = ...,
        exclude: set[str] | None = ...,
        include: set[str] | None = ...,
        repr: bool = True,
        doc: str | None = ...,
    ) -> computed_field[NoReturn, NoReturn]:
        ...

    def __call__(self: computed_field[NoReturn, NoReturn], fget: Callable[[Any], _G2]) -> computed_field[_G2, NoReturn]:
        ...

    def getter(self, __fget: Callable[[Any], _G2]) -> computed_field[_G2, _S1]:
        ...

    def setter(self, __fset: Callable[[Any, _S2], None]) -> computed_field[_G1, _S2]:
        ...

    def deleter(self, __fdel: Callable[[Any], None]) -> computed_field[_G1, _S1]:
        ...

    def __get__(self, __obj: Any, __type: type | None = ...) -> _G1:
        ...

    def __set__(self, __obj: Any, __value: _S1) -> None:
        ...

    def __delete__(self, __obj: Any) -> None:
        ...


class Square:
    side: float

    @computed_field
    def area(self) -> float:
        return self.side**2

    @computed_field(alias="The random number")
    def random_n(self) -> int:
        return randint(0, 1_000)


reveal_type(Square().area)
reveal_type(Square().random_n)
Type of "Square().area" is "float"
Type of "Square().random_n" is "int"

Unfurtunately, both mypy and pycharm still don't know how to handle (generic) descriptors.

Actually, mypy also is able to infer properties types here!

@caniko
Copy link
Contributor

caniko commented Sep 1, 2022

Another thing that I thought of is to include a keyword argument for computing all computed fields when running model.dict() or model.json().

Example:

model.dict(call_computed_fields=True)

Will evaluate and provide dict with all computed fields computed.

@nickswiss
Copy link

@PrettyWood Any update on this feature, think it is a great addition and gets rid of accomplishing this in validators. Thank you!

samuelcolvin added a commit to pydantic/pydantic-core that referenced this pull request Apr 16, 2023
@samuelcolvin samuelcolvin mentioned this pull request Apr 17, 2023
1 task
samuelcolvin added a commit to pydantic/pydantic-core that referenced this pull request Apr 17, 2023
* implement computed fields 🎉

* dataclass properties

* catch errors in properties

* fix include and exclude

* add example from pydantic/pydantic#2625, fix by_alias

* fix on older python
@samuelcolvin
Copy link
Member

Thank you all (especially @PrettyWood) for 2 years (! 🙈) of patience.

I've migrated this feature to pydantic V2 and pydantic-core, see #5502.

Thank you so much @PrettyWood for your work on this, I've used some of your code, and most of your tests and docs in the new PR 🙇 🙏.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
deferred deferred until future release or until something else gets done
Projects
None yet