Repository: litestar-org/litestar

Issue:
Bug: pydantic PrivateAttr fields are inspected for type hints
### Description

If a private field is annotated with unresolvable ForwardRef, openapi schema build fails (see example)

### URL to code causing the issue

_No response_

### MCVE

```python
from typing import TYPE_CHECKING

from litestar import Litestar, post
from litestar._openapi.plugin import OpenAPIPlugin
from pydantic import BaseModel

if TYPE_CHECKING:
    from typing import Any


class Model(BaseModel):
    class Config:
        underscore_attrs_are_private = True

    _value: "Any"


@post("/")
def hello_world(data: Model) -> dict[str, str]:
    """Keeping the tradition alive with hello world."""
    return {"hello": "world"}


def main():
    app = Litestar(route_handlers=[hello_world], )

    app.plugins.get(OpenAPIPlugin).provide_openapi()


if __name__ == '__main__':
    main()
```


### Steps to reproduce

```bash
run the example
```


### Screenshots

```bash
no screenshots
```


### Logs

```bash
.../litestar_schema/app.py:21: LitestarWarning: Use of a synchronous callable <function hello_world at 0x102eafeb0> without setting sync_to_thread is discouraged since synchronous callables can block the main thread if they perform blocking operations. If the callable is guaranteed to be non-blocking, you can set sync_to_thread=False to skip this warning, or set the environmentvariable LITESTAR_WARN_IMPLICIT_SYNC_TO_THREAD=0 to disable warnings of this type entirely.
  def hello_world(data: Model) -> dict[str, str]:
Traceback (most recent call last):
  File ".../litestar_schema/app.py", line 33, in <module>
    main()
  File ".../litestar_schema/app.py", line 29, in main
    app.plugins.get(OpenAPIPlugin).provide_openapi()
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/plugin.py", line 46, in provide_openapi
    self._openapi_schema = self._build_openapi_schema()
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/plugin.py", line 37, in _build_openapi_schema
    openapi.paths = {
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/plugin.py", line 38, in <dictcomp>
    route.path_format or "/": create_path_item_for_route(context, route)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/path_item.py", line 137, in create_path_item_for_route
    return path_item_factory.create_path_item()
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/path_item.py", line 42, in create_path_item
    operation = self.create_operation_for_handler_method(route_handler, HttpMethod(http_method))
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/path_item.py", line 66, in create_operation_for_handler_method
    request_body = create_request_body(
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/request_body.py", line 49, in create_request_body
    schema = schema_creator.for_field_definition(data_field)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/schema_generation/schema.py", line 327, in for_field_definition
    result = self.for_plugin(field_definition, plugin_for_annotation)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/_openapi/schema_generation/schema.py", line 487, in for_plugin
    schema = plugin.to_openapi_schema(field_definition=field_definition, schema_creator=self)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/contrib/pydantic/pydantic_schema_plugin.py", line 234, in to_openapi_schema
    return self.for_pydantic_model(field_definition=field_definition, schema_creator=schema_creator)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/contrib/pydantic/pydantic_schema_plugin.py", line 250, in for_pydantic_model
    unwrapped_annotation, annotation_hints = pydantic_get_unwrapped_annotation_and_type_hints(annotation)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/litestar/contrib/pydantic/utils.py", line 162, in pydantic_get_unwrapped_annotation_and_type_hints
    return annotation, get_type_hints(annotation, include_extras=True)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/site-packages/typing_extensions.py", line 1104, in get_type_hints
    hint = typing.get_type_hints(
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/typing.py", line 1836, in get_type_hints
    value = _eval_type(value, base_globals, base_locals)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/typing.py", line 327, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
  File "/Users/mike0sv/mambaforge/envs/evidently/lib/python3.10/typing.py", line 695, in _evaluate
    eval(self.__forward_code__, globalns, localns),
  File "<string>", line 1, in <module>
NameError: name 'Any' is not defined. Did you mean: 'any'?
```


### Litestar Version

litestar==2.5.1


### Platform

- [ ] Linux
- [X] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/3150">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/3150/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/3150/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
~~Okay so I think the issue here is that we don't respect the `Config.underscore_attrs_are_private`. IMO we simply shouldn't touch the underscore fields at all if this is set. @litestar-org/maintainers?~~

We actually do. This is a different issue.
Issue here is that while we do exclude those fields from the schema, we still try to extract their type annotations. 
@mike0sv There's a fix in #3151. Can you give it a try and confirm that it solve the issue for you?
<!--closing-comment-->
This issue has been closed in #3151. The change will be included in the upcoming patch release.
I tested it and it works for MCVE, but it still fails for evidently codebase. I narrowed it down to the fact that our "Model" class is actually a generic. Here is the new MCVE
```py
from typing import Generic, Optional, TYPE_CHECKING, TypeVar

from litestar import Litestar, post
from litestar._openapi.plugin import OpenAPIPlugin
from pydantic import BaseModel

if TYPE_CHECKING:
    from typing import Any

T = TypeVar("T")


class Model(BaseModel, Generic[T]):
    class Config:
        underscore_attrs_are_private = True

    _value: Optional["Any"]


@post("/")
def hello_world(data: Model) -> dict[str, str]:
    """Keeping the tradition alive with hello world."""
    return {"hello": "world"}


def main():
    app = Litestar(route_handlers=[hello_world], )

    app.plugins.get(OpenAPIPlugin).provide_openapi()


if __name__ == '__main__':
    main()
```
This seems to be exclusive to Pydantic V1 though; I couldn't reproduce with V2.

Resolving patch:
diff --git a/litestar/contrib/pydantic/utils.py b/litestar/contrib/pydantic/utils.py
--- a/litestar/contrib/pydantic/utils.py
+++ b/litestar/contrib/pydantic/utils.py
@@ -132,7 +132,7 @@ def pydantic_get_type_hints_with_generics_resolved(
     model_annotations: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
     if pydantic_v2 is Empty or (pydantic_v1 is not Empty and is_class_and_subclass(annotation, pydantic_v1.BaseModel)):
-        return get_type_hints_with_generics_resolved(annotation)
+        return get_type_hints_with_generics_resolved(annotation, type_hints=model_annotations)
 
     origin = pydantic_unwrap_and_get_origin(annotation)
     if origin is None:
diff --git a/litestar/utils/typing.py b/litestar/utils/typing.py
--- a/litestar/utils/typing.py
+++ b/litestar/utils/typing.py
@@ -235,6 +235,7 @@ def get_type_hints_with_generics_resolved(
     globalns: dict[str, Any] | None = None,
     localns: dict[str, Any] | None = None,
     include_extras: bool = False,
+    type_hints: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
     """Get the type hints for the given object after resolving the generic types as much as possible.
 
@@ -243,15 +244,18 @@ def get_type_hints_with_generics_resolved(
         globalns: The global namespace.
         localns: The local namespace.
         include_extras: A flag indicating whether to include the ``Annotated[T, ...]`` or not.
+        type_hints: Already resolved type hints
     """
     origin = get_origin(annotation)
 
     if origin is None:
         # Implies the generic types have not been specified in the annotation
-        type_hints = get_type_hints(annotation, globalns=globalns, localns=localns, include_extras=include_extras)
+        if type_hints is None:  # pragma: no cover
+            type_hints = get_type_hints(annotation, globalns=globalns, localns=localns, include_extras=include_extras)
         typevar_map = {p: p for p in annotation.__parameters__}
     else:
-        type_hints = get_type_hints(origin, globalns=globalns, localns=localns, include_extras=include_extras)
+        if type_hints is None:  # pragma: no cover
+            type_hints = get_type_hints(origin, globalns=globalns, localns=localns, include_extras=include_extras)
         # the __parameters__ is only available on the origin itself and not the annotation
         typevar_map = dict(zip(origin.__parameters__, get_args(annotation)))
 


Test patch:
diff --git a/tests/unit/test_contrib/test_pydantic/test_schema_plugin.py b/tests/unit/test_contrib/test_pydantic/test_schema_plugin.py
--- a/tests/unit/test_contrib/test_pydantic/test_schema_plugin.py
+++ b/tests/unit/test_contrib/test_pydantic/test_schema_plugin.py
@@ -91,16 +91,36 @@ class Config:
     _underscore_field: "foo"  # type: ignore[name-defined]  # noqa: F821
 
 
-class V2ModelWithPrivateFields(pydantic_v2.BaseModel):
+class V1GenericModelWithPrivateFields(pydantic_v1.generics.GenericModel, Generic[T]):  # pyright: ignore
     class Config:
         underscore_fields_are_private = True
 
+    _field: str = pydantic_v1.PrivateAttr()
+    # include an invalid annotation here to ensure we never touch those fields
+    _underscore_field: "foo"  # type: ignore[name-defined]  # noqa: F821
+
+
+class V2ModelWithPrivateFields(pydantic_v2.BaseModel):
+    _field: str = pydantic_v2.PrivateAttr()
+    # include an invalid annotation here to ensure we never touch those fields
+    _underscore_field: "foo"  # type: ignore[name-defined] # noqa: F821
+
+
+class V2GenericModelWithPrivateFields(pydantic_v2.BaseModel, Generic[T]):
     _field: str = pydantic_v2.PrivateAttr()
     # include an invalid annotation here to ensure we never touch those fields
     _underscore_field: "foo"  # type: ignore[name-defined] # noqa: F821
 
 
-@pytest.mark.parametrize("model_class", [V1ModelWithPrivateFields, V2ModelWithPrivateFields])
+@pytest.mark.parametrize(
+    "model_class",
+    [
+        V1ModelWithPrivateFields,
+        V1GenericModelWithPrivateFields,
+        V2ModelWithPrivateFields,
+        V2GenericModelWithPrivateFields,
+    ],
+)
 def test_exclude_private_fields(model_class: Type[Union[pydantic_v1.BaseModel, pydantic_v2.BaseModel]]) -> None:
     # https://github.com/litestar-org/litestar/issues/3150
     schema = PydanticSchemaPlugin.for_pydantic_model(