Repository: strawberry-graphql/strawberry
Pull request #50 (2019-04-22 21:35:45)

Fix broken circular imports
Not sure how to fix this yet, I've added a test case for it.


Patch:
diff --git a/.isort.cfg b/.isort.cfg
index c035de57..e2f12e97 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -11,5 +11,5 @@ known_pytest=pytest
 known_future_library=future
 known_standard_library=types,requests
 default_section=THIRDPARTY
-known_third_party = click,graphql,hupper,pygments,starlette,uvicorn
+known_third_party = base,click,github_release,graphql,hupper,pygments,starlette,uvicorn
 sections=FUTURE,STDLIB,PYTEST,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 00000000..353c6986
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,4 @@
+Release type: minor
+
+This changes field to be lazy by default, allowing to use circular dependencies
+when declaring types.
diff --git a/strawberry/field.py b/strawberry/field.py
index fc3d0377..2210799d 100644
--- a/strawberry/field.py
+++ b/strawberry/field.py
@@ -1,13 +1,14 @@
-from typing import get_type_hints
+import typing
 
 import dataclasses
 from graphql import GraphQLField
 
 from .constants import IS_STRAWBERRY_FIELD, IS_STRAWBERRY_INPUT
 from .exceptions import MissingArgumentsAnnotationsError, MissingReturnAnnotationError
-from .type_converter import get_graphql_type_for_annotation
+from .type_converter import REGISTRY, get_graphql_type_for_annotation
 from .utils.dict_to_type import dict_to_type
 from .utils.inspect import get_func_args
+from .utils.lazy_property import lazy_property
 from .utils.str_converters import to_camel_case, to_snake_case
 from .utils.typing import (
     get_list_annotation,
@@ -17,6 +18,67 @@ from .utils.typing import (
 )
 
 
+class LazyFieldWrapper:
+    """A lazy wrapper for a strawberry field.
+    This allows to use cyclic dependencies in a strawberry fields:
+
+    >>> @strawberry.type
+    >>> class TypeA:
+    >>>     @strawberry.field
+    >>>     def type_b(self, info) -> "TypeB":
+    >>>         from .type_b import TypeB
+    >>>         return TypeB()
+    """
+
+    def __init__(self, obj, is_subscription, **kwargs):
+        self._wrapped_obj = obj
+        self.is_subscription = is_subscription
+        self.kwargs = kwargs
+
+        if callable(self._wrapped_obj):
+            self._check_has_annotations()
+
+    def _check_has_annotations(self):
+        # using annotations without passing from typing.get_type_hints
+        # as we don't the actually types for the annotations
+        annotations = self._wrapped_obj.__annotations__
+        name = self._wrapped_obj.__name__
+
+        if "return" not in annotations:
+            raise MissingReturnAnnotationError(name)
+
+        function_arguments = set(get_func_args(self._wrapped_obj)) - {"self", "info"}
+
+        arguments_annotations = {
+            key: value
+            for key, value in annotations.items()
+            if key not in ["info", "return"]
+        }
+
+        annotated_function_arguments = set(arguments_annotations.keys())
+        arguments_missing_annotations = (
+            function_arguments - annotated_function_arguments
+        )
+
+        if len(arguments_missing_annotations) > 0:
+            raise MissingArgumentsAnnotationsError(name, arguments_missing_annotations)
+
+    def __getattr__(self, attr):
+        if attr in self.__dict__:
+            return getattr(self, attr)
+
+        return getattr(self._wrapped_obj, attr)
+
+    def __call__(self, *args, **kwargs):
+        return self._wrapped_obj(self, *args, **kwargs)
+
+    @lazy_property
+    def field(self):
+        return _get_field(
+            self._wrapped_obj, is_subscription=self.is_subscription, **self.kwargs
+        )
+
+
 class strawberry_field:
     """A small wrapper for a field in strawberry.
 
@@ -51,10 +113,7 @@ class strawberry_field:
 
         self.kwargs["description"] = self.description or wrap.__doc__
 
-        wrap.field = _get_field(
-            wrap, is_subscription=self.is_subscription, **self.kwargs
-        )
-        return wrap
+        return LazyFieldWrapper(wrap, self.is_subscription, **self.kwargs)
 
 
 def convert_args(args, annotations):
@@ -91,29 +150,18 @@ def convert_args(args, annotations):
 
 
 def _get_field(wrap, *, is_subscription=False, **kwargs):
-    annotations = get_type_hints(wrap)
+    annotations = typing.get_type_hints(wrap, None, REGISTRY)
 
     name = wrap.__name__
 
-    if "return" not in annotations:
-        raise MissingReturnAnnotationError(name)
-
     field_type = get_graphql_type_for_annotation(annotations["return"], name)
 
-    function_arguments = set(get_func_args(wrap)) - {"self", "info"}
-
     arguments_annotations = {
         key: value
         for key, value in annotations.items()
         if key not in ["info", "return"]
     }
 
-    annotated_function_arguments = set(arguments_annotations.keys())
-    arguments_missing_annotations = function_arguments - annotated_function_arguments
-
-    if len(arguments_missing_annotations) > 0:
-        raise MissingArgumentsAnnotationsError(name, arguments_missing_annotations)
-
     arguments = {
         to_camel_case(name): get_graphql_type_for_annotation(annotation, name)
         for name, annotation in arguments_annotations.items()
diff --git a/strawberry/utils/lazy_property.py b/strawberry/utils/lazy_property.py
new file mode 100644
index 00000000..af99a5dd
--- /dev/null
+++ b/strawberry/utils/lazy_property.py
@@ -0,0 +1,11 @@
+def lazy_property(fn):
+    """Decorator that makes a property lazy-evaluated."""
+    attr_name = "_lazy_" + fn.__name__
+
+    @property
+    def _lazy_property(self):
+        if not hasattr(self, attr_name):
+            setattr(self, attr_name, fn(self))
+        return getattr(self, attr_name)
+
+    return _lazy_property


Test patch:
diff --git a/tests/test_cyclic/__init__.py b/tests/test_cyclic/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_cyclic/test_cyclic.py b/tests/test_cyclic/test_cyclic.py
new file mode 100644
index 00000000..d4b88718
--- /dev/null
+++ b/tests/test_cyclic/test_cyclic.py
@@ -0,0 +1,47 @@
+import textwrap
+
+import strawberry
+
+
+def test_cyclic_import():
+    from .type_a import TypeA
+    from .type_b import TypeB
+
+    @strawberry.type
+    class Query:
+        a: TypeA
+        b: TypeB
+
+    assert (
+        repr(Query(None, None))
+        == textwrap.dedent(
+            """
+            type Query {
+              a: TypeA!
+              b: TypeB!
+            }
+            """
+        ).strip()
+    )
+
+    assert (
+        repr(TypeA())
+        == textwrap.dedent(
+            """
+            type TypeA {
+              typeB: TypeB!
+            }
+            """
+        ).strip()
+    )
+
+    assert (
+        repr(TypeB())
+        == textwrap.dedent(
+            """
+            type TypeB {
+              typeA: TypeA!
+            }
+            """
+        ).strip()
+    )
diff --git a/tests/test_cyclic/type_a.py b/tests/test_cyclic/type_a.py
new file mode 100644
index 00000000..4e75f74e
--- /dev/null
+++ b/tests/test_cyclic/type_a.py
@@ -0,0 +1,16 @@
+import typing
+
+import strawberry
+
+
+if typing.TYPE_CHECKING:
+    from .type_b import TypeB
+
+
+@strawberry.type
+class TypeA:
+    @strawberry.field
+    def type_b(self, info) -> "TypeB":
+        from .type_b import TypeB
+
+        return TypeB()
diff --git a/tests/test_cyclic/type_b.py b/tests/test_cyclic/type_b.py
new file mode 100644
index 00000000..434fb7de
--- /dev/null
+++ b/tests/test_cyclic/type_b.py
@@ -0,0 +1,16 @@
+import typing
+
+import strawberry
+
+
+if typing.TYPE_CHECKING:
+    from .type_a import TypeA
+
+
+@strawberry.type
+class TypeB:
+    @strawberry.field
+    def type_a(self, info) -> "TypeA":
+        from .type_a import TypeA
+
+        return TypeA()