Repository: pydantic/pydantic
Pull request: Fix type checking issue with `model_fields` and `model_computed_fields`
Fix https://github.com/pydantic/pydantic/issues/10907

Problem statement:
2.10: Type checking error on `model_fields` and `model_computed_fields` with mypy
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

Using pydantic<2.10.0, mypy correctly accepts using `MyModel.model_fields.keys()`. With pydantic 2.10.0, I get this error: `error: "Callable[[BaseModel], dict[str, FieldInfo]]" has no attribute "keys"  [attr-defined]`. If I downgrade to pydantic 2.9.2, that error goes away.

### Example Code

```Python
from pydantic import BaseModel

class Model(BaseModel):
    field_1: int

print(list(Model.model_fields.keys()))
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.10.0
        pydantic-core version: 2.27.0
          pydantic-core build: profile=release pgo=false
                 install path: /Users/path/to/.venv/lib/python3.11/site-packages/pydantic
               python version: 3.11.10 (main, Sep  9 2024, 20:58:40) [Clang 18.1.8 ]
                     platform: macOS-15.1.1-arm64-arm-64bit
             related packages: mypy-1.13.0 typing_extensions-4.12.2
                       commit: unknown
```

Hints from issue discussion:
Huh, not able to repro with `pyright`.

Will look into a fix, thanks for the report!
This is odd bc:

```py
    @property
    def model_fields(self) -> dict[str, FieldInfo]:
        """Get metadata about the fields defined on the model.

        Deprecation warning: you should be getting this information from the model class, not from an instance.
        In V3, this property will be removed from the `BaseModel` class.

        Returns:
            A mapping of field names to [`FieldInfo`][pydantic.fields.FieldInfo] objects.
        """
        # Must be set for `GenerateSchema.model_schema` to work for a plain `BaseModel` annotation, hence the default here.
        return getattr(self, '__pydantic_fields__', {})
```

I'm surprised this isn't recognized as just `dict[str, FieldInfo]`
Ah, I think it's because of my changes [here](https://github.com/pydantic/pydantic/pull/10493)

Guessing we need to do something clever in an `if TYPE_CHECKING` block because `model_fields` should be recognized as a `ClassVar`

Note, my comment here:
```
# TODO: V3 - remove `model_fields` and `model_computed_fields` properties from the `BaseModel` class - they should only
    # be accessible on the model class, not on instances. We have these purely for backwards compatibility with Pydantic <v2.10.
    # This is similar to the fact that we have __fields__ defined here (on `BaseModel`) and on `ModelMetaClass`.
```

These are causing the issue

Commit message: adding type checking tests

Patch to review:
diff --git a/pydantic/main.py b/pydantic/main.py
index bd9a92075c7..4aa8c96b1f0 100644
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -240,34 +240,39 @@ def __init__(self, /, **data: Any) -> None:
     # The following line sets a flag that we use to determine when `__init__` gets overridden by the user
     __init__.__pydantic_base_init__ = True  # pyright: ignore[reportFunctionMemberAccess]
 
-    # TODO: V3 - remove `model_fields` and `model_computed_fields` properties from the `BaseModel` class - they should only
-    # be accessible on the model class, not on instances. We have these purely for backwards compatibility with Pydantic <v2.10.
-    # This is similar to the fact that we have __fields__ defined here (on `BaseModel`) and on `ModelMetaClass`.
-    @property
-    def model_fields(self) -> dict[str, FieldInfo]:
-        """Get metadata about the fields defined on the model.
+    if TYPE_CHECKING:
+        model_fields: ClassVar[dict[str, FieldInfo]]
+        model_computed_fields: ClassVar[dict[str, ComputedFieldInfo]]
+    else:
+        # TODO: V3 - remove `model_fields` and `model_computed_fields` properties from the `BaseModel` class - they should only
+        # be accessible on the model class, not on instances. We have these purely for backwards compatibility with Pydantic <v2.10.
+        # This is similar to the fact that we have __fields__ defined here (on `BaseModel`) and on `ModelMetaClass`.
+        # Another problem here is that there's no great way to have class properties :(
+        @property
+        def model_fields(self) -> dict[str, FieldInfo]:
+            """Get metadata about the fields defined on the model.
 
-        Deprecation warning: you should be getting this information from the model class, not from an instance.
-        In V3, this property will be removed from the `BaseModel` class.
+            Deprecation warning: you should be getting this information from the model class, not from an instance.
+            In V3, this property will be removed from the `BaseModel` class.
 
-        Returns:
-            A mapping of field names to [`FieldInfo`][pydantic.fields.FieldInfo] objects.
-        """
-        # Must be set for `GenerateSchema.model_schema` to work for a plain `BaseModel` annotation, hence the default here.
-        return getattr(self, '__pydantic_fields__', {})
+            Returns:
+                A mapping of field names to [`FieldInfo`][pydantic.fields.FieldInfo] objects.
+            """
+            # Must be set for `GenerateSchema.model_schema` to work for a plain `BaseModel` annotation, hence the default here.
+            return getattr(self, '__pydantic_fields__', {})
 
-    @property
-    def model_computed_fields(self) -> dict[str, ComputedFieldInfo]:
-        """Get metadata about the computed fields defined on the model.
+        @property
+        def model_computed_fields(self) -> dict[str, ComputedFieldInfo]:
+            """Get metadata about the computed fields defined on the model.
 
-        Deprecation warning: you should be getting this information from the model class, not from an instance.
-        In V3, this property will be removed from the `BaseModel` class.
+            Deprecation warning: you should be getting this information from the model class, not from an instance.
+            In V3, this property will be removed from the `BaseModel` class.
 
-        Returns:
-            A mapping of computed field names to [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.
-        """
-        # Must be set for `GenerateSchema.model_schema` to work for a plain `BaseModel` annotation, hence the default here.
-        return getattr(self, '__pydantic_computed_fields__', {})
+            Returns:
+                A mapping of computed field names to [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.
+            """
+            # Must be set for `GenerateSchema.model_schema` to work for a plain `BaseModel` annotation, hence the default here.
+            return getattr(self, '__pydantic_computed_fields__', {})
 
     @property
     def model_extra(self) -> dict[str, Any] | None:
diff --git a/tests/typechecking/base_model.py b/tests/typechecking/base_model.py
index bcbb979e5e5..0f13fc9685a 100644
--- a/tests/typechecking/base_model.py
+++ b/tests/typechecking/base_model.py
@@ -28,3 +28,6 @@ class Knight(BaseModel):
 
 
 k = Knight()  # type: ignore[call-arg]  # pyright: ignore[reportCallIssue]
+
+MyModel.model_fields.keys()
+MyModel.model_computed_fields.keys()


Code review comments:
diff_hunk: @@ -28,3 +28,6 @@ class Knight(BaseModel):
 
 
 k = Knight()  # type: ignore[call-arg]  # pyright: ignore[reportCallIssue]
+
+MyModel.model_fields.keys()
+MyModel.model_computed_fields.keys()
path: tests/typechecking/base_model.py
text: @user1:
```suggestion
assert_type(Knight.model_fields, dict[str, FieldInfo])
assert_type(Knight.model_computed_fields, dict[str, ComputedFieldInfo])
assert_type(k.model_fields, dict[str, FieldInfo])
assert_type(k.model_computed_fields, dict[str, ComputedFieldInfo])
```

importing `assert_type` from `typing_extensions`