mkl
mkl

Reputation: 675

Private attributes in `pydantic`

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?

Upvotes: 13

Views: 30212

Answers (2)

stefanitsky
stefanitsky

Reputation: 483

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

Upvotes: 0

Franz Felberer
Franz Felberer

Reputation: 173

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)

Upvotes: 4

Related Questions