Repository: strawberry-graphql/strawberry

Issue:
`from __future__ import annotations` incompatible with `strawberry.Private`
Using a minor variation from the [getting started tutorial](https://strawberry.rocks/docs) (addition of `from __future__ import annotations` and the use of `strawberry.Private`) results in a traceback. The code works as expected when commenting out the `from __future__` import.

```
from __future__ import annotations

import typing
import strawberry

@strawberry.type
class Book:
    title: str
    author: str
    
    foo: strawberry.Private[int]


def get_books():
    return [
        Book(
            title='The Great Gatsby',
            author='F. Scott Fitzgerald',
            foo=1,
        ),
    ]


@strawberry.type
class Query:
    books: typing.List[Book] = strawberry.field(resolver=get_books)


schema = strawberry.Schema(query=Query)
```

Starting the built-in strawberry server results in the following traceback:
```
$ strawberry server schema
Traceback (most recent call last):
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/graphql/type/definition.py", line 735, in fields
    fields = resolve_thunk(self._fields)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/graphql/type/definition.py", line 264, in resolve_thunk
    return thunk() if callable(thunk) else thunk
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/strawberry/schema/schema_converter.py", line 280, in get_graphql_fields
    graphql_fields[field_name] = self.from_field(field)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/strawberry/schema/schema_converter.py", line 136, in from_field
    field_type = self.from_non_optional(field.type)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/strawberry/schema/schema_converter.py", line 261, in from_non_optional
    of_type = self.from_type(type_)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/strawberry/schema/schema_converter.py", line 469, in from_type
    raise TypeError(f"Unexpected type '{type_}'")
TypeError: Unexpected type 'typing.Annotated[int, <strawberry.private.StrawberryPrivate object at 0x7f72dcd2b820>]'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/path/to/project/virtualenv/bin/strawberry", line 8, in <module>
    sys.exit(run())
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/click/core.py", line 1128, in __call__
    return self.main(*args, **kwargs)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/click/core.py", line 1053, in main
    rv = self.invoke(ctx)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/click/core.py", line 1659, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/click/core.py", line 1395, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/click/core.py", line 754, in invoke
    return __callback(*args, **kwargs)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/strawberry/cli/commands/server.py", line 46, in server
    schema_symbol = import_module_symbol(schema, default_symbol_name="schema")
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/strawberry/utils/importer.py", line 15, in import_module_symbol
    module = importlib.import_module(module_name)
  File "/dls_sw/apps/mamba/0.19.1/lib/python3.10/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 883, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/path/to/project/./schema.py", line 29, in <module>
    schema = strawberry.Schema(query=Query)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/strawberry/schema/schema.py", line 84, in __init__
    self._schema = GraphQLSchema(
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/graphql/type/schema.py", line 208, in __init__
    collect_referenced_types(query)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/graphql/type/schema.py", line 423, in collect_referenced_types
    collect_referenced_types(field.type)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/graphql/type/schema.py", line 422, in collect_referenced_types
    for field in named_type.fields.values():
  File "/dls_sw/apps/mamba/0.19.1/lib/python3.10/functools.py", line 970, in __get__
    val = self.func(instance)
  File "/path/to/project/virtualenv/lib/python3.10/site-packages/graphql/type/definition.py", line 737, in fields
    raise TypeError(f"{self.name} fields cannot be resolved. {error}")
TypeError: Book fields cannot be resolved. Unexpected type 'typing.Annotated[int, <strawberry.private.StrawberryPrivate object at 0x7f72dcd2b820>]'
```

Python version:
```
$ python
Python 3.10.1 | packaged by conda-forge | (main, Dec 22 2021, 01:39:05) [GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
```

Strawberry version:
```
$ pip show strawberry-graphql
Name: strawberry-graphql
Version: 0.94.0
Summary: A library for creating GraphQL APIs
Home-page: https://strawberry.rocks/
Author: Patrick Arminio
Author-email: patrick.arminio@gmail.com
License: MIT
Location: /path/to/project/virtualenv/lib/python3.10/site-packages
Requires: python-multipart, backports.cached-property, click, graphql-core, python-dateutil, pygments, typing_extensions, sentinel
Required-by: 
```

OS: RHEL7.9

<!-- POLAR PLEDGE BADGE START -->
## Upvote & Fund

- We're using [Polar.sh](https://polar.sh/strawberry-graphql) so you can upvote and help fund this issue.
- We receive the funding once the issue is completed & confirmed by you.
- Thank you in advance for helping prioritize & fund our backlog.

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



Discussion:
 we call `dataclasses.fields` to get the field types. if we import annotations, the actual types aren't available to us. its just a string.

```
Field(name='foo',type='strawberry.Private[int]',  ...)
```

vs without the import

```
Field(name='foo',type=typing.Annotated[int, <strawberry.private.StrawberryPrivate object at 0x7fd249796370>] ,...)
```

still checking it out, i think we can call .resolve() to fix it?
```
# _get_fields
            type_resolved = StrawberryAnnotation(
                annotation=field_type,
                namespace=module.__dict__,
            ).resolve()
            if is_private(type_resolved):
                continue
```
Very easy to reproduce. This should be considered a bug.
```python
import strawberry

@strawberry.type
class PrivateForward:
    not_seen: "strawberry.Private[int]"

strawberry.Schema(query=PrivateForward)
```

I'm investigating this now
`annotations` from future implies to resolve types later, so it’s perfectly normal that the return type is a string.

This behavior was scheduled to be included by default with Python 3.10 but was postponed for this reason: multiple libraries are not compatible with it.

To make this work, one should add code to resolve the type from string: https://www.python.org/dev/peps/pep-0563/#resolving-type-hints-at-runtime
 
```python
from typing import get_type_hints
class A:
    ...
class B:
    def toto() -> A:
        ...
print(B.toto.__annotations__)
print(get_type_hints(B.toto))
```
Returns:
```
{'return': <class '__main__.A'>}
{'return': <class '__main__.A'>}
```

```python
from __future__ import annotations
from typing import get_type_hints
class A:
    ...
class B:
    def toto() -> A:
        ...
print(B.toto.__annotations__)
print(get_type_hints(B.toto))
```
Returns:
```
{'return': 'A'}
{'return': <class '__main__.A'>}
```
> To make this work, one should add code to resolve the type from string: https://www.python.org/dev/peps/pep-0563/#resolving-type-hints-at-runtime

We do this already :) unfortunately we do check for Private before that happens, so we need to refactor the check to make it work properly :)
Examining this problem I came to the following conclusion: If one creates a schema where the `Private` field type refers to a class defined after the decorated class, then runtime type resolution during construction of the `StrawberryField` would fail. This is because the referred class does not yet exist in the module namespace. For example:

```python

from __future__ import annotations

import strawberry

@strawberry.type
class Foo:
    bar: strawberry.Private[Bar]

class Bar:
   pass
```

In the above, when `_get_fields` is called by `strawberry.type` the class `Bar` does not yet exist in the module namespace.
To counteract this problem, I think the check for `Private` fields should to be delayed to when the actual schema is built. A potential solution implementing this approach would be to modify `GraphQLCoreConverter.from_object()` with a call to `is_private` like so:

```python
    def from_object(self, object_type: TypeDefinition) -> GraphQLObjectType:
        ...
        def get_graphql_fields() -> Dict[str, GraphQLField]:
            graphql_fields = {}

            for field in object_type.fields:
                if not is_private(field.type):
                    field_name = self.config.name_converter.from_field(field)

                    graphql_fields[field_name] = self.from_field(field)

            return graphql_fields
```

To avoid duplication, the checks for private fields in `_get_fields` could be removed I think. Please, let me know if I missed something @patrick91 @BryceBeagle. If you like this solution, I would be happy to write up a PR 😄 
thanks @skilkis ! @BryceBeagle is now working on the solution, he'll update this soon

Resolving patch:
diff --git a/strawberry/exceptions.py b/strawberry/exceptions.py
--- a/strawberry/exceptions.py
+++ b/strawberry/exceptions.py
@@ -131,6 +131,15 @@ def __init__(self, annotation):
         super().__init__(message)
 
 
+class UnresolvedFieldTypeError(Exception):
+    def __init__(self, field_name: str):
+        message = (
+            f"Could not resolve the type of '{field_name}'. Check that the class is "
+            "accessible from the global module scope."
+        )
+        super().__init__(message)
+
+
 class MissingFieldAnnotationError(Exception):
     def __init__(self, field_name: str):
         message = (
diff --git a/strawberry/field.py b/strawberry/field.py
--- a/strawberry/field.py
+++ b/strawberry/field.py
@@ -40,6 +40,9 @@
 _RESOLVER_TYPE = Union[StrawberryResolver, Callable, staticmethod, classmethod]
 
 
+UNRESOLVED = object()
+
+
 class StrawberryField(dataclasses.Field):
     python_name: str
 
@@ -192,7 +195,7 @@ def base_resolver(self, resolver: StrawberryResolver) -> None:
         _ = resolver.arguments
 
     @property  # type: ignore
-    def type(self) -> Union[StrawberryType, type]:  # type: ignore
+    def type(self) -> Union[StrawberryType, type, Literal[UNRESOLVED]]:  # type: ignore
         # We are catching NameError because dataclasses tries to fetch the type
         # of the field from the class before the class is fully defined.
         # This triggers a NameError error when using forward references because
@@ -212,7 +215,7 @@ def type(self) -> Union[StrawberryType, type]:  # type: ignore
 
             return self.type_annotation.resolve()
         except NameError:
-            return None  # type: ignore
+            return UNRESOLVED
 
     @type.setter
     def type(self, type_: Any) -> None:
diff --git a/strawberry/schema/schema_converter.py b/strawberry/schema/schema_converter.py
--- a/strawberry/schema/schema_converter.py
+++ b/strawberry/schema/schema_converter.py
@@ -1,7 +1,18 @@
 from __future__ import annotations
 
 from enum import Enum
-from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union, cast
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+    cast,
+)
 
 from graphql import (
     GraphQLArgument,
@@ -31,9 +42,11 @@
 from strawberry.exceptions import (
     MissingTypesForGenericError,
     ScalarAlreadyRegisteredError,
+    UnresolvedFieldTypeError,
 )
-from strawberry.field import StrawberryField
+from strawberry.field import UNRESOLVED, StrawberryField
 from strawberry.lazy_type import LazyType
+from strawberry.private import is_private
 from strawberry.schema.config import StrawberryConfig
 from strawberry.schema.types.scalar import _make_scalar_type
 from strawberry.type import StrawberryList, StrawberryOptional, StrawberryType
@@ -163,6 +176,53 @@ def from_input_field(self, field: StrawberryField) -> GraphQLInputField:
             deprecation_reason=field.deprecation_reason,
         )
 
+    FieldType = TypeVar("FieldType", GraphQLField, GraphQLInputField)
+
+    @staticmethod
+    def _get_thunk_mapping(
+        fields: List[StrawberryField],
+        name_converter: Callable[[StrawberryField], str],
+        field_converter: Callable[[StrawberryField], FieldType],
+    ) -> Dict[str, FieldType]:
+        """Create a GraphQL core `ThunkMapping` mapping of field names to field types.
+
+        This method filters out remaining `strawberry.Private` annotated fields that
+        could not be filtered during the initialization of a `TypeDefinition` due to
+        postponed type-hint evaluation (PEP-563). Performing this filtering now (at
+        schema conversion time) ensures that all types to be included in the schema
+        should have already been resolved.
+
+        Raises:
+            TypeError: If the type of a field in ``fields`` is `UNRESOLVED`
+        """
+        thunk_mapping = {}
+
+        for f in fields:
+            if f.type is UNRESOLVED:
+                raise UnresolvedFieldTypeError(f.name)
+
+            if not is_private(f.type):
+                thunk_mapping[name_converter(f)] = field_converter(f)
+        return thunk_mapping
+
+    def get_graphql_fields(
+        self, type_definition: TypeDefinition
+    ) -> Dict[str, GraphQLField]:
+        return self._get_thunk_mapping(
+            fields=type_definition.fields,
+            name_converter=self.config.name_converter.from_field,
+            field_converter=self.from_field,
+        )
+
+    def get_graphql_input_fields(
+        self, type_definition: TypeDefinition
+    ) -> Dict[str, GraphQLInputField]:
+        return self._get_thunk_mapping(
+            fields=type_definition.fields,
+            name_converter=self.config.name_converter.from_field,
+            field_converter=self.from_input_field,
+        )
+
     def from_input_object(self, object_type: type) -> GraphQLInputObjectType:
         type_definition = object_type._type_definition  # type: ignore
 
@@ -174,18 +234,9 @@ def from_input_object(self, object_type: type) -> GraphQLInputObjectType:
             assert isinstance(graphql_object_type, GraphQLInputObjectType)  # For mypy
             return graphql_object_type
 
-        def get_graphql_fields() -> Dict[str, GraphQLInputField]:
-            graphql_fields = {}
-            for field in type_definition.fields:
-                field_name = self.config.name_converter.from_field(field)
-
-                graphql_fields[field_name] = self.from_input_field(field)
-
-            return graphql_fields
-
         graphql_object_type = GraphQLInputObjectType(
             name=type_name,
-            fields=get_graphql_fields,
+            fields=lambda: self.get_graphql_input_fields(type_definition),
             description=type_definition.description,
         )
 
@@ -206,18 +257,9 @@ def from_interface(self, interface: TypeDefinition) -> GraphQLInterfaceType:
             assert isinstance(graphql_interface, GraphQLInterfaceType)  # For mypy
             return graphql_interface
 
-        def get_graphql_fields() -> Dict[str, GraphQLField]:
-            graphql_fields = {}
-
-            for field in interface.fields:
-                field_name = self.config.name_converter.from_field(field)
-                graphql_fields[field_name] = self.from_field(field)
-
-            return graphql_fields
-
         graphql_interface = GraphQLInterfaceType(
             name=interface_name,
-            fields=get_graphql_fields,
+            fields=lambda: self.get_graphql_fields(interface),
             interfaces=list(map(self.from_interface, interface.interfaces)),
             description=interface.description,
         )
@@ -243,16 +285,6 @@ def from_object(self, object_type: TypeDefinition) -> GraphQLObjectType:
             assert isinstance(graphql_object_type, GraphQLObjectType)  # For mypy
             return graphql_object_type
 
-        def get_graphql_fields() -> Dict[str, GraphQLField]:
-            graphql_fields = {}
-
-            for field in object_type.fields:
-                field_name = self.config.name_converter.from_field(field)
-
-                graphql_fields[field_name] = self.from_field(field)
-
-            return graphql_fields
-
         is_type_of: Optional[Callable[[Any, GraphQLResolveInfo], bool]]
         if object_type.is_type_of:
             is_type_of = object_type.is_type_of
@@ -266,7 +298,7 @@ def is_type_of(obj: Any, _info: GraphQLResolveInfo) -> bool:
 
         graphql_object_type = GraphQLObjectType(
             name=object_type_name,
-            fields=get_graphql_fields,
+            fields=lambda: self.get_graphql_fields(object_type),
             interfaces=list(map(self.from_interface, object_type.interfaces)),
             description=object_type.description,
             is_type_of=is_type_of,


Test patch:
diff --git a/tests/schema/test_private_field.py b/tests/schema/test_private_field.py
--- a/tests/schema/test_private_field.py
+++ b/tests/schema/test_private_field.py
@@ -1,3 +1,5 @@
+from dataclasses import dataclass
+
 import pytest
 
 import strawberry
@@ -55,3 +57,60 @@ def age_in_months(self) -> int:
     assert result.data == {
         "ageInMonths": 84,
     }
+
+
+@strawberry.type
+class Query:
+    not_seen: "strawberry.Private[SensitiveData]"
+
+    @strawberry.field
+    def accessible_info(self) -> str:
+        return self.not_seen.info
+
+
+@dataclass
+class SensitiveData:
+    value: int
+    info: str
+
+
+def test_private_field_with_str_annotations():
+    """Check compatibility of strawberry.Private with annotations as string."""
+
+    schema = strawberry.Schema(query=Query)
+
+    result = schema.execute_sync(
+        "query { accessibleInfo }",
+        root_value=Query(not_seen=SensitiveData(1, "foo")),
+    )
+    assert result.data == {"accessibleInfo": "foo"}
+
+    # Check if querying `notSeen` raises error and no data is returned
+    assert "notSeen" not in str(schema)
+    failed_result = schema.execute_sync(
+        "query { notSeen }", root_value=Query(not_seen=SensitiveData(1, "foo"))
+    )
+    assert failed_result.data is None
+
+
+def test_private_field_defined_outside_module_scope():
+    """Check compatibility of strawberry.Private when defined outside module scope."""
+
+    @strawberry.type
+    class LocallyScopedQuery:
+        not_seen: "strawberry.Private[LocallyScopedSensitiveData]"
+
+        @strawberry.field
+        def accessible_info(self) -> str:
+            return self.not_seen.info
+
+    @dataclass
+    class LocallyScopedSensitiveData:
+        value: int
+        info: str
+
+    with pytest.raises(TypeError, match=r"Could not resolve the type of 'not_seen'."):
+        schema = strawberry.Schema(query=LocallyScopedQuery)
+
+        # If schema-conversion does not raise, check if private field is visible
+        assert "notSeen" not in str(schema)