Repository: pydantic/pydantic

Issue:
V2: config parameter to warning about missing annotations
We have [logic](https://github.com/pydantic/pydantic/blob/eaa2ad14c71138600de301d5f0ebf8fda6775788/pydantic/_internal/_model_construction.py#L203) to deal with the case where an annotation is not defined when a model is constructed, at the moment this silently stops the schema/validator build and `__pydantic_model_complete__` is left `False` in the hope that the missing annotation(s) can be defined and the model completed via `model_rebuild`.

I think we should have a config flag, default True which raising a warning when this happens - it should be a `UserWarning` so it gets printed by default.

Needs #4516 to be merged before this can be worked on, but would be amazing if someone could take this on.


Discussion:
I'd be happy to contribute to this. Would be my first contribution to pydantic – do you think it's suitable?
Yes absolutely, please submit a PR.
Sweet! I'll get to working on this over the weekend / next week. (Meanwhile, is it possible for you to `assign` to me? Easier for me to be organized. If this is against your management preference, that's ok too.
I would also be interested in working on this if it opens up again!
@samuelcolvin I was looking at #4683 and I could not create a unit test that demonstrates the problem you're describing.

What do you mean exactly by "the case where an annotation is not defined when a model is constructed"?

I tried a variety of different things and I could not make a test where `assert f.__pydantic_model_complete__` would fail.

for example:

```python
def test_warn_on_model_missing_annotation():
    
    Foo = ForwardRef('Foo')

    class Foo(BaseModel):
        a: int = 123
        b: Foo = None
    
    f = Foo().construct()
    
    assert f.__pydantic_model_complete__, 'Model should be complete'
```

Would you be willing to share a simple (xfail) test that demonstrates the desired pass conditions?

Thanks.
I I think of you define `Foo` after `Bar` you should get the error.

Or definitely if `Foo` is another pydantic model with references `Bar`.

Resolving patch:
diff --git a/docs/examples/types_undefined_warning.py b/docs/examples/types_undefined_warning.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/types_undefined_warning.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+
+# This example shows how Book and Person types reference each other.
+# We will demonstrate how to suppress the undefined types warning
+# when define such models.
+
+
+class Book(BaseModel):
+    title: str
+    author: Person  # note the `Person` type is not yet defined
+
+    # Suppress undefined types warning so we can continue defining our models.
+    class Config:
+        undefined_types_warning = False
+
+
+class Person(BaseModel):
+    name: str
+    books_read: list[Book] | None = None
+
+
+# Now, we can rebuild the `Book` model, since the `Person` model is now defined.
+# Note: there's no need to call `model_rebuild()` on `Person`,
+# it's already complete.
+Book.model_rebuild()
+
+# Let's create some instances of our models, to demonstrate that they work.
+python_crash_course = Book(
+    title='Python Crash Course',
+    author=Person(name='Eric Matthes'),
+)
+jane_doe = Person(name='Jane Doe', books_read=[python_crash_course])
+
+assert jane_doe.dict(exclude_unset=True) == {
+    'name': 'Jane Doe',
+    'books_read': [
+        {
+            'title': 'Python Crash Course',
+            'author': {'name': 'Eric Matthes'},
+        },
+    ],
+}
diff --git a/pydantic/_internal/_model_construction.py b/pydantic/_internal/_model_construction.py
--- a/pydantic/_internal/_model_construction.py
+++ b/pydantic/_internal/_model_construction.py
@@ -139,9 +139,10 @@ def complete_model_class(
     except PydanticUndefinedAnnotation as e:
         if raise_errors:
             raise
-        cls.__pydantic_validator__ = MockValidator(  # type: ignore[assignment]
-            f'`{name}` is not fully defined, you should define `{e}`, then call `{name}.model_rebuild()`'
-        )
+        warning_string = f'`{name}` is not fully defined, you should define `{e}`, then call `{name}.model_rebuild()`'
+        if cls.__config__.undefined_types_warning:
+            raise UserWarning(warning_string)
+        cls.__pydantic_validator__ = MockValidator(warning_string)  # type: ignore[assignment]
         # here we have to set __get_pydantic_validation_schema__ so we can try to rebuild the model later
         cls.__get_pydantic_validation_schema__ = partial(  # type: ignore[attr-defined]
             deferred_model_get_pydantic_validation_schema, cls
@@ -188,6 +189,8 @@ def build_inner_schema(  # noqa: C901
     model_ref = f'{module_name}.{name}'
     self_schema = core_schema.new_class_schema(cls, core_schema.recursive_reference_schema(model_ref))
     local_ns = {name: Annotated[SelfType, SchemaRef(self_schema)]}
+
+    # get type hints and raise a PydanticUndefinedAnnotation if any types are undefined
     try:
         type_hints = _typing_extra.get_type_hints(cls, global_ns, local_ns, include_extras=True)
     except NameError as e:
@@ -207,6 +210,10 @@ def build_inner_schema(  # noqa: C901
         if ann_name.startswith('_') or _typing_extra.is_classvar(ann_type):
             continue
 
+        # raise a PydanticUndefinedAnnotation if type is undefined
+        if isinstance(ann_type, typing.ForwardRef):
+            raise PydanticUndefinedAnnotation(ann_type.__forward_arg__)
+
         for base in bases:
             if hasattr(base, ann_name):
                 raise NameError(
diff --git a/pydantic/config.py b/pydantic/config.py
--- a/pydantic/config.py
+++ b/pydantic/config.py
@@ -83,6 +83,7 @@ class BaseConfig:
     validate_assignment: bool = False
     error_msg_templates: Dict[str, str] = {}  # TODO remove
     arbitrary_types_allowed: bool = False  # TODO default True, or remove
+    undefined_types_warning: bool = True  # TODO review docs
     orm_mode: bool = False  # TODO rename to from_attributes
     alias_generator: Optional[Callable[[str], str]] = None
     keep_untouched: Tuple[type, ...] = ()  # TODO remove??


Test patch:
diff --git a/tests/test_forward_ref.py b/tests/test_forward_ref.py
--- a/tests/test_forward_ref.py
+++ b/tests/test_forward_ref.py
@@ -1,3 +1,4 @@
+import re
 import sys
 from typing import Optional, Tuple
 
@@ -44,6 +45,9 @@ def module():
         class Foo(BaseModel):
             a: 'Bar' = None
 
+            class Config:
+                undefined_types_warning = False
+
         class Bar(BaseModel):
             b: 'Foo'
 
@@ -80,6 +84,9 @@ class Foo(BaseModel):
             foo: 'Foo'
             bar: 'Bar'  # noqa: F821
 
+            class Config:
+                undefined_types_warning = False
+
     assert hasattr(module.Foo, '__fields__') is False
 
 
@@ -468,6 +475,9 @@ class Spec(BaseModel):
 class PSpec(Spec):
     g: Optional[GSpec]
 
+    class Config:
+        undefined_types_warning = False
+
 
 class GSpec(Spec):
     p: Optional[PSpec]
@@ -693,6 +703,9 @@ class Team(BaseModel):
             name: str
             heroes: list[HeroRef]  # noqa: F821
 
+            class Config:
+                undefined_types_warning = False
+
         class Hero(BaseModel):
             name: str
             teams: list[Team]
@@ -787,6 +800,9 @@ def more_nested():
                 class Bar(BaseModel):
                     b: 'Foo'
 
+                    class Config:
+                        undefined_types_warning = False
+
                 return Bar
 
             return more_nested()
@@ -830,6 +846,9 @@ def nested():
             class Bar(BaseModel):
                 b: 'Foo'
 
+                class Config:
+                    undefined_types_warning = False
+
             class Foo(BaseModel):
                 a: int
 
@@ -840,3 +859,99 @@ class Foo(BaseModel):
     bar_model = module.nested()
     assert bar_model.__pydantic_model_complete__ is True
     assert bar_model(b={'a': 1}).dict() == {'b': {'a': 1}}
+
+
+def pytest_raises_undefined_types_warning(defining_class_name, missing_type_name):
+    """Returns a pytest.raises context manager that checks for the correct undefined types warning message.
+    usage: `with pytest_raises_undefined_types_warning(class_name='Foobar', missing_class_name='UndefinedType'):`
+    """
+
+    return pytest.raises(
+        UserWarning,
+        match=re.escape(
+            # split this f-string into two lines to pass linting (line too long)
+            f'`{defining_class_name}` is not fully defined, you should define `{missing_type_name}`, '
+            f'then call `{defining_class_name}.model_rebuild()`',
+        ),
+    )
+
+
+#   NOTE: the `undefined_types_warning` tests below are "statically parameterized" (i.e. have Duplicate Code).
+#   The initial attempt to refactor them into a single parameterized test was not strateforward, due to the use of the
+#   `create_module` fixture and the need for `from __future__ import annotations` be the first line in a module.
+#
+#   Test Parameters:
+#     1. config setting: (1a) default behavior vs (1b) overriding with Config setting:
+#     2. type checking approach: (2a) `from __future__ import annotations` vs (2b) `ForwardRef`
+#
+#   The parameter tags "1a", "1b", "2a", and "2b" are used in the test names below, to indicate which combination
+#   of conditions the test is covering.
+
+
+def test_undefined_types_warning_1a_raised_by_default_2a_future_annotations(create_module):
+    with pytest_raises_undefined_types_warning(defining_class_name='Foobar', missing_type_name='UndefinedType'):
+        create_module(
+            # language=Python
+            """
+from __future__ import annotations
+from pydantic import BaseModel
+
+class Foobar(BaseModel):
+    a: UndefinedType
+"""
+        )
+
+
+def test_undefined_types_warning_1a_raised_by_default_2b_forward_ref(create_module):
+    with pytest_raises_undefined_types_warning(defining_class_name='Foobar', missing_type_name='UndefinedType'):
+
+        @create_module
+        def module():
+            from typing import ForwardRef
+
+            from pydantic import BaseModel
+
+            UndefinedType = ForwardRef('UndefinedType')
+
+            class Foobar(BaseModel):
+                a: UndefinedType
+
+
+def test_undefined_types_warning_1b_suppressed_via_config_2a_future_annotations(create_module):
+    module = create_module(
+        # language=Python
+        """
+from __future__ import annotations
+from pydantic import BaseModel
+
+class Foobar(BaseModel):
+    a: UndefinedType
+    # Suppress the undefined_types_warning
+    class Config:
+        undefined_types_warning = False
+"""
+    )
+    # Since we're testing the absence of a warning, it's important to confirm pydantic was actually run.
+    # The presence of the `__pydantic_model_complete__` is a good indicator of this.
+    assert module.Foobar.__pydantic_model_complete__ is False
+
+
+def test_undefined_types_warning_1b_suppressed_via_config_2b_forward_ref(create_module):
+    @create_module
+    def module():
+        from typing import ForwardRef
+
+        from pydantic import BaseModel
+
+        UndefinedType = ForwardRef('UndefinedType')
+
+        class Foobar(BaseModel):
+            a: UndefinedType
+
+            # Suppress the undefined_types_warning
+            class Config:
+                undefined_types_warning = False
+
+    # Since we're testing the absence of a warning, it's important to confirm pydantic was actually run.
+    # The presence of the `__pydantic_model_complete__` is a good indicator of this.
+    assert module.Foobar.__pydantic_model_complete__ is False