9

I'm trying to get the following behavior with pydantic.BaseModel:

class MyClass:
    def __init__(self, value: T) -> None:
        self._value = value

    # Maybe:
    @property
    def value(self) -> T:
        return self._value

    # Maybe:
    @value.setter
    def value(self, value: T) -> None:
        # ...
        self._value = value

If T is also a pydantic model, then recursive initialization using dictionaries should work:

# Initialize `x._value` with `T(foo="bar", spam="ham")`:
x = MyClass(value={"foo": "bar", "spam": "ham"})

Note that _value is initialized using the kwargs value. Validation must also be available for private fields.

The pydantic docs (PrivateAttr, etc.) seem to imply that pydantic will never expose private attributes. I'm sure there is some hack for this. But is there an idiomatic way to achieve the behavior in pydantic? Or should I just use a custom class?

mkl
  • 635
  • 1
  • 6
  • 16
  • The question is unclear to me. If you want to do some calculation between the exposed `value` and the private `_value`, you can still use the `@property` and `@value.setter`s. After all, the computation has to be done in some function. If you want to validate that value is of type `T` or could be parsed into type `T`, you could run `value = T.parse_obj(value)` in your init and the setter method before working with it (towards `_value`). Finally, if you also want `value` exposed in `dict()` (which `json()` and equality tests make use of) you can add a custom `dict` function as well. – camo Dec 02 '21 at 17:50
  • @camo Which part of the question is unclear to you? Also, you seem to have a suggestion how to solve the problem. If you like, you could provide a full answer. – mkl Dec 02 '21 at 17:59
  • I tried an idea this morning with `parse_obj` but indeed the problem seems to be, that pydantic does not allow for custom `setter` methods - unless patched as done in the answer by @franz-felberer. – camo Dec 05 '21 at 08:36

2 Answers2

4

Not sure it this solution is advisable, based on: https://github.com/samuelcolvin/pydantic/issues/1577 https://github.com/samuelcolvin/pydantic/issues/655

import inspect
from typing import Dict

from pydantic import BaseModel, PrivateAttr
from pydantic.main import no_type_check


class PatchedModel(BaseModel):
    @no_type_check
    def __setattr__(self, name, value):
        """
        To be able to use properties with setters
        """
        try:
            super().__setattr__(name, value)
        except ValueError as e:
            setters = inspect.getmembers(
                self.__class__,
                predicate=lambda x: isinstance(x, property) and x.fset is not None
            )
            for setter_name, func in setters:
                if setter_name == name:
                    object.__setattr__(self, name, value)
                    break
            else:
                raise e


class T(BaseModel):
    value1: str
    value2: int


class MyClassPydantic(PatchedModel):
    _value: T = PrivateAttr()

    def __init__(self, value: Dict, **kwargs):
        super().__init__(**kwargs)
        object.__setattr__(self, "_value", T(**value))

    @property
    def value(self) -> T:
        return self._value

    @value.setter
    def value(self, value: T) -> None:
        self._value: T = value

    # To avoid the PatchedModel(BaseModel) use instead
    # def set_value(self, value: T) -> None:
    #    self._value: T = value


if __name__ == "__main__":
    my_pydantic_class = MyClassPydantic({"value1": "test1", "value2": 1})
    print(my_pydantic_class.value)
    my_pydantic_class.value = T(value1="test2", value2=2)
    # my_pydantic_class.set_value(T(value1="test2", value2=2))
    print(my_pydantic_class.value)
Franz Felberer
  • 163
  • 1
  • 8
  • Could you also use `self._value = T.parse_obj(value)` in the `__init__`? That would avoid explicit dependence on `value` being a dictionary. Same goes for the `value.setter`, you could ensure that `self._value = T.parse_obj(value)` instead of passing `value` directly. – camo Dec 05 '21 at 08:40
0

I ended up with something like this, it acts like a private field, but i can change it by public methods:


import inspect

from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field


class Entity(BaseModel):
    """Base entity class."""

    def __setattr__(self, name, value):
        if "self" not in inspect.currentframe().f_back.f_locals:
            raise Exception("set attr is protected")

        super().__setattr__(name, value)


class PostId(UUID):
    """Post unique id."""


class Post(Entity):
    """Post."""

    post_id: PostId = Field(description='unique post id')
    title: Optional[str] = Field(None, description='title')

    def change_title(self, new_title: str) -> None:
        """Changes title."""
        self.title = new_title

I just looking at inspect.currentframe().f_back.f_locals and looking for self key.

Ispired by accessify

Tested with this little test:

from uuid import uuid4

import pytest
import post_pydantic


def test_pydantic():
    """Test pydantic varriant."""
    post_id = uuid4()
    post = post_pydantic.Post(post_id=post_id)

    with pytest.raises(Exception) as e:
        post.post_id = uuid4()

    assert post.post_id == post_id

    assert e.value.args[0] == "set attr is protected"

    new_title = "New title"
    post.change_title(new_title)

    assert post.title == new_title
stefanitsky
  • 443
  • 7
  • 9
  • 1
    This doesn't make something private. This will only enforce that the attribute is set from a function with `self` as one of the arguments (it doesn't even have to be the first one). See https://gist.github.com/Jordan-Cottle/195c8298a4e899e204ed0aa08d46c8ea – Jordan Cottle Aug 16 '22 at 19:35