Repository: pydantic/pydantic

Issue:
Private cached properties are included in BaseModel.__iter__
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

Attributes whose name has a leading underscore are not treated as fields by Pydantic, and are not included in the model schema. However this doesn't seem to be the case for fields injected by `cached_property`.

### Example Code

```Python
from functools import cached_property

from pydantic import BaseModel


class Model(BaseModel):
    foo: int

    @cached_property
    def _foo(self):
        return -self.foo


m = Model(foo=1)
assert list(k for k, v in m) == ["foo"]

m._foo
keys = list(k for k, v in m)
assert keys == ["foo"], keys
```

Raises `AssertionError: ['foo', '_foo']`


### Python, Pydantic & OS Version

```Text
pydantic version: 2.3.0
        pydantic-core version: 2.6.3
          pydantic-core build: profile=release pgo=false
               python version: 3.10.11 | packaged by conda-forge | (main, May 10 2023, 19:01:19) [Clang 14.0.6 ]
                     platform: macOS-13.2.1-arm64-arm-64bit
     optional deps. installed: ['email-validator', 'typing-extensions']
```



Discussion:
hmmm, looks like when calling a property with `cached_property`, the `cache_property` manipulates the class `__dict__` directly,  here is the code snippet from `functools.cached_property`

```py
        try:
            cache = instance.__dict__  # <<< get the class __dict__
        except AttributeError:  # not all objects have __dict__ (e.g. class defines slots)
            msg = (
                f"No '__dict__' attribute on {type(instance).__name__!r} "
                f"instance to cache {self.attrname!r} property."
            )
            raise TypeError(msg) from None
                   # ... some code
                    val = self.func(instance)
                    try:
                        cache[self.attrname] = val  # <<< adds the attribute to the class instance
                    except TypeError:
                        msg = (
                            f"The '__dict__' attribute on {type(instance).__name__!r} instance "
                            f"does not support item assignment for caching {self.attrname!r} property."
                        )
                        raise TypeError(msg) from None
                    # ... some code
```

and when you iterate over your `Model`, the `__iter__` returns the models`__dict__`, that's why it changes and we don't not see it in `Model.model_fields` 

so it doesn't look like a bug, but more like an unexpected behavior from the `cached_property` side 
I'm not sure how this cannot be considered a bug, especially when there is an easy fix: filter out the `__dict__` keys with leading underscore.
@gsakkis, thanks for reporting! I agree - this is definitely a bug. Will open a PR shortly to address this...

Resolving patch:
diff --git a/pydantic/fields.py b/pydantic/fields.py
--- a/pydantic/fields.py
+++ b/pydantic/fields.py
@@ -7,6 +7,12 @@
 import typing
 from copy import copy
 from dataclasses import Field as DataclassField
+
+try:
+    from functools import cached_property  # type: ignore
+except ImportError:
+    # python 3.7
+    cached_property = None
 from typing import Any, ClassVar
 from warnings import warn
 
@@ -991,6 +997,18 @@ def computed_field(__func: PropertyT) -> PropertyT:
     ...
 
 
+def _wrapped_property_is_private(property_: cached_property | property) -> bool:  # type: ignore
+    """Returns true if provided property is private, False otherwise."""
+    wrapped_name: str = ''
+
+    if isinstance(property_, property):
+        wrapped_name = getattr(property_.fget, '__name__', '')
+    elif isinstance(property_, cached_property):  # type: ignore
+        wrapped_name = getattr(property_.func, '__name__', '')  # type: ignore
+
+    return wrapped_name.startswith('_') and not wrapped_name.startswith('__')
+
+
 def computed_field(
     __f: PropertyT | None = None,
     *,
@@ -998,7 +1016,7 @@ def computed_field(
     alias_priority: int | None = None,
     title: str | None = None,
     description: str | None = None,
-    repr: bool = True,
+    repr: bool | None = None,
     return_type: Any = PydanticUndefined,
 ) -> PropertyT | typing.Callable[[PropertyT], PropertyT]:
     """Decorator to include `property` and `cached_property` when serializing models or dataclasses.
@@ -1092,6 +1110,31 @@ def a(self) -> str:
         #> ValueError("you can't override a field with a computed field")
     ```
 
+    Private properties decorated with `@computed_field` have `repr=False` by default.
+
+    ```py
+    from functools import cached_property
+
+    from pydantic import BaseModel, computed_field
+
+    class Model(BaseModel):
+        foo: int
+
+        @computed_field
+        @cached_property
+        def _private_cached_property(self) -> int:
+            return -self.foo
+
+        @computed_field
+        @property
+        def _private_property(self) -> int:
+            return -self.foo
+
+    m = Model(foo=1)
+    print(repr(m))
+    #> M(foo=1)
+    ```
+
     Args:
         __f: the function to wrap.
         alias: alias to use when serializing this computed field, only used when `by_alias=True`
@@ -1099,7 +1142,8 @@ def a(self) -> str:
         title: Title to used when including this computed field in JSON Schema, currently unused waiting for #4697
         description: Description to used when including this computed field in JSON Schema, defaults to the functions
             docstring, currently unused waiting for #4697
-        repr: whether to include this computed field in model repr
+        repr: whether to include this computed field in model repr.
+            Default is `False` for private properties and `True` for public properties.
         return_type: optional return for serialization logic to expect when serializing to JSON, if included
             this must be correct, otherwise a `TypeError` is raised.
             If you don't include a return type Any is used, which does runtime introspection to handle arbitrary
@@ -1118,7 +1162,13 @@ def dec(f: Any) -> Any:
         # if the function isn't already decorated with `@property` (or another descriptor), then we wrap it now
         f = _decorators.ensure_property(f)
         alias_priority = (alias_priority or 2) if alias is not None else None
-        dec_info = ComputedFieldInfo(f, return_type, alias, alias_priority, title, description, repr)
+
+        if repr is None:
+            repr_: bool = False if _wrapped_property_is_private(property_=f) else True
+        else:
+            repr_ = repr
+
+        dec_info = ComputedFieldInfo(f, return_type, alias, alias_priority, title, description, repr_)
         return _decorators.PydanticDescriptorProxy(f, dec_info)
 
     if __f is None:
diff --git a/pydantic/main.py b/pydantic/main.py
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -887,7 +887,7 @@ class MyModel(BaseModel, extra='allow'):
 
     def __iter__(self) -> TupleGenerator:
         """So `dict(model)` works."""
-        yield from self.__dict__.items()
+        yield from [(k, v) for (k, v) in self.__dict__.items() if not k.startswith('_')]
         extra = self.__pydantic_extra__
         if extra:
             yield from extra.items()


Test patch:
diff --git a/tests/test_computed_fields.py b/tests/test_computed_fields.py
--- a/tests/test_computed_fields.py
+++ b/tests/test_computed_fields.py
@@ -402,7 +402,7 @@ def test_private_computed_field():
     class MyModel(BaseModel):
         x: int
 
-        @computed_field
+        @computed_field(repr=True)
         def _double(self) -> int:
             return self.x * 2
 
diff --git a/tests/test_private_attributes.py b/tests/test_private_attributes.py
--- a/tests/test_private_attributes.py
+++ b/tests/test_private_attributes.py
@@ -4,7 +4,7 @@
 import pytest
 from pydantic_core import PydanticUndefined
 
-from pydantic import BaseModel, ConfigDict, PrivateAttr
+from pydantic import BaseModel, ConfigDict, PrivateAttr, computed_field
 
 
 def test_private_attribute():
@@ -393,3 +393,59 @@ class BaseConfig(BaseModel):
     )
 
     assert module.BaseConfig._FIELD_UPDATE_STRATEGY == {}
+
+
+@pytest.mark.skipif(not hasattr(functools, 'cached_property'), reason='cached_property is not available')
+def test_private_properties_not_included_in_iter_cached_property() -> None:
+    class Model(BaseModel):
+        foo: int
+
+        @computed_field
+        @functools.cached_property
+        def _foo(self) -> int:
+            return -self.foo
+
+    m = Model(foo=1)
+    assert '_foo' not in list(k for k, _ in m)
+
+
+def test_private_properties_not_included_in_iter_property() -> None:
+    class Model(BaseModel):
+        foo: int
+
+        @computed_field
+        @property
+        def _foo(self) -> int:
+            return -self.foo
+
+    m = Model(foo=1)
+    assert '_foo' not in list(k for k, _ in m)
+
+
+def test_private_properties_not_included_in_repr_by_default_property() -> None:
+    class Model(BaseModel):
+        foo: int
+
+        @computed_field
+        @property
+        def _private_property(self) -> int:
+            return -self.foo
+
+    m = Model(foo=1)
+    m_repr = repr(m)
+    assert '_private_property' not in m_repr
+
+
+@pytest.mark.skipif(not hasattr(functools, 'cached_property'), reason='cached_property is not available')
+def test_private_properties_not_included_in_repr_by_default_cached_property() -> None:
+    class Model(BaseModel):
+        foo: int
+
+        @computed_field
+        @functools.cached_property
+        def _private_cached_property(self) -> int:
+            return -self.foo
+
+    m = Model(foo=1)
+    m_repr = repr(m)
+    assert '_private_cached_property' not in m_repr