Repository: strawberry-graphql/strawberry

Issue:
Scalars should be a type instead of an object
Using `strawberry.scalar` results in a `ScalarWrapper` object instead of a type. This is currently giving issues with the latest version of `pyright` because it doesn't like objects being used to annotate dataclasses fields.

For example:

```python
import strawberry
from strawberry.scalars import JSON

@strawberry.type
class Foo:
    b: JSON
```
Gives the following error:
```
error: Expected class type but received "((cls: Unknown) -> ScalarWrapper) | ScalarWrapper"
    "(cls: Unknown) -> ScalarWrapper" is not a class
    "ScalarWrapper" is not a class (reportGeneralTypeIssues)
```

obs. This is related to https://github.com/strawberry-graphql/strawberry/issues/1659, which now is also giving errors for pyright


Discussion:
@strawberry-graphql/core I can try to solve this and was going to propose to create a registry at the module to map wrapped scalars to the type definition, but looking at the code where scalars are used I saw a lot of `# TODO: Replace with StrawberryScalar`, which doesn't exist yet.

Are there any existing plans on how to implement "StrawberryScalar"?
@bellini666 this is a bit complicates, since it ties into what we wanted to do for the internals refactoring.

I have a draft doc about this here: https://gist.github.com/patrick91/5db99abb90b2485679126dbb0e5da20d

I also think we should change return values to be types and add an attribute for the strawberry information (similar to what we do with `@strawberry.type`)

A quick solution for this would be just to lie to pyright and say that `strawberry.scalar` returns a type, so it won't complain (I think we do this somewhere else already), what do you think?
@patrick91 oh, thanks for sharing that doc :)

Yes, lying to pyright by casting the return value of `strawberry.scalar` to the scalar itself should solve all static typing issues.

There're still some remaining issues with this approach though, for example, the same issue with `strawberry.auto` that prevents non-type objects from being used with `Annotated`. This means that if someone wants to add a description to an argument that is using a custom scalar with like `arg: Annotated[JSON, strawbery.argument(description="Some Description")]`, it will produce a runtime error.

But if changing that is part of the internal refactoring, we can `cast` for now and accept that downside for the moment and have it fixed the right way with the refactoring.

Resolving patch:
diff --git a/strawberry/auto.py b/strawberry/auto.py
--- a/strawberry/auto.py
+++ b/strawberry/auto.py
@@ -1,4 +1,23 @@
-import sentinel
+from typing import Any, ClassVar, Optional
 
+from typing_extensions import Annotated, Final
 
-auto = sentinel.create("auto")
+
+class StrawberryAuto:
+    _instance: ClassVar[Optional["StrawberryAuto"]] = None
+
+    def __new__(cls, *args, **kwargs):
+        if cls._instance is not None:
+            return cls._instance
+
+        cls._instance = super().__new__(cls, *args, **kwargs)
+        return cls._instance
+
+    def __str__(self):
+        return "auto"
+
+    def __repr__(self):
+        return "<auto>"
+
+
+auto: Final = Annotated[Any, StrawberryAuto()]
diff --git a/strawberry/custom_scalar.py b/strawberry/custom_scalar.py
--- a/strawberry/custom_scalar.py
+++ b/strawberry/custom_scalar.py
@@ -1,5 +1,5 @@
 from dataclasses import dataclass
-from typing import Callable, Mapping, Optional, TypeVar, Union
+from typing import Any, Callable, Mapping, Optional, TypeVar, Union, overload
 
 from graphql import GraphQLScalarType
 
@@ -8,6 +8,9 @@
 from .utils.str_converters import to_camel_case
 
 
+_T = TypeVar("_T", bound=type)
+
+
 def identity(x):
     return x
 
@@ -71,16 +74,46 @@ def _process_scalar(
     return wrapper
 
 
+@overload
+def scalar(
+    *,
+    name: Optional[str] = None,
+    description: Optional[str] = None,
+    specified_by_url: Optional[str] = None,
+    serialize: Callable = identity,
+    parse_value: Optional[Callable] = None,
+    parse_literal: Optional[Callable] = None,
+) -> Callable[[_T], _T]:
+    ...
+
+
+@overload
+def scalar(
+    cls: _T,
+    *,
+    name: Optional[str] = None,
+    description: Optional[str] = None,
+    specified_by_url: Optional[str] = None,
+    serialize: Callable = identity,
+    parse_value: Optional[Callable] = None,
+    parse_literal: Optional[Callable] = None,
+) -> _T:
+    ...
+
+
+# FIXME: We are tricking pyright into thinking that we are returning the given type
+# here or else it won't let us use any custom scalar to annotate attributes in
+# dataclasses/types. This should be properly solved when implementing StrawberryScalar
 def scalar(
     cls=None,
     *,
-    name: str = None,
+    name: Optional[str] = None,
     description: Optional[str] = None,
     specified_by_url: Optional[str] = None,
     serialize: Callable = identity,
     parse_value: Optional[Callable] = None,
     parse_literal: Optional[Callable] = None,
-):
+) -> Any:
     """Annotates a class or type as a GraphQL custom scalar.
 
     Example usages:
diff --git a/strawberry/schema/types/scalar.py b/strawberry/schema/types/scalar.py
--- a/strawberry/schema/types/scalar.py
+++ b/strawberry/schema/types/scalar.py
@@ -41,18 +41,22 @@ def _make_scalar_definition(scalar_type: GraphQLScalarType) -> ScalarDefinition:
     )
 
 
+def _get_scalar_definition(scalar) -> ScalarDefinition:
+    return scalar._scalar_definition
+
+
 DEFAULT_SCALAR_REGISTRY: Dict[object, ScalarDefinition] = {
-    type(None): base_scalars.Void._scalar_definition,
-    None: base_scalars.Void._scalar_definition,
+    type(None): _get_scalar_definition(base_scalars.Void),
+    None: _get_scalar_definition(base_scalars.Void),
     str: _make_scalar_definition(GraphQLString),
     int: _make_scalar_definition(GraphQLInt),
     float: _make_scalar_definition(GraphQLFloat),
     bool: _make_scalar_definition(GraphQLBoolean),
     ID: _make_scalar_definition(GraphQLID),
-    UUID: base_scalars.UUID._scalar_definition,
-    Upload: Upload._scalar_definition,
-    datetime.date: base_scalars.Date._scalar_definition,
-    datetime.datetime: base_scalars.DateTime._scalar_definition,
-    datetime.time: base_scalars.Time._scalar_definition,
-    decimal.Decimal: base_scalars.Decimal._scalar_definition,
+    UUID: _get_scalar_definition(base_scalars.UUID),
+    Upload: _get_scalar_definition(Upload),
+    datetime.date: _get_scalar_definition(base_scalars.Date),
+    datetime.datetime: _get_scalar_definition(base_scalars.DateTime),
+    datetime.time: _get_scalar_definition(base_scalars.Time),
+    decimal.Decimal: _get_scalar_definition(base_scalars.Decimal),
 }


Test patch:
diff --git a/tests/experimental/pydantic/test_basic.py b/tests/experimental/pydantic/test_basic.py
--- a/tests/experimental/pydantic/test_basic.py
+++ b/tests/experimental/pydantic/test_basic.py
@@ -5,7 +5,6 @@
 import pytest
 
 import pydantic
-import sentinel
 
 import strawberry
 from strawberry.enum import EnumDefinition
@@ -106,7 +105,8 @@ class UserType:
 
 
 def test_auto_fields_other_sentinel():
-    other_sentinel = sentinel.create("other_sentinel")
+    class other_sentinel:
+        pass
 
     class User(pydantic.BaseModel):
         age: int
diff --git a/tests/pyright/test_auto.py b/tests/pyright/test_auto.py
new file mode 100644
--- /dev/null
+++ b/tests/pyright/test_auto.py
@@ -0,0 +1,48 @@
+from .utils import Result, requires_pyright, run_pyright, skip_on_windows
+
+
+pytestmark = [skip_on_windows, requires_pyright]
+
+
+CODE = """
+import strawberry
+
+
+@strawberry.type
+class SomeType:
+    foobar: strawberry.auto
+
+
+obj1 = SomeType(foobar=1)
+obj2 = SomeType(foobar="some text")
+obj3 = SomeType(foobar={"some key": "some value"})
+
+reveal_type(obj1.foobar)
+reveal_type(obj2.foobar)
+reveal_type(obj3.foobar)
+"""
+
+
+def test_pyright():
+    results = run_pyright(CODE)
+
+    assert results == [
+        Result(
+            type="information",
+            message='Type of "obj1.foobar" is "Any"',
+            line=14,
+            column=13,
+        ),
+        Result(
+            type="information",
+            message='Type of "obj2.foobar" is "Any"',
+            line=15,
+            column=13,
+        ),
+        Result(
+            type="information",
+            message='Type of "obj3.foobar" is "Any"',
+            line=16,
+            column=13,
+        ),
+    ]
diff --git a/tests/pyright/test_scalars.py b/tests/pyright/test_scalars.py
new file mode 100644
--- /dev/null
+++ b/tests/pyright/test_scalars.py
@@ -0,0 +1,73 @@
+from .utils import Result, requires_pyright, run_pyright, skip_on_windows
+
+
+pytestmark = [skip_on_windows, requires_pyright]
+
+
+CODE = """
+import strawberry
+from strawberry.scalars import ID, JSON, Base16, Base32, Base64
+
+
+@strawberry.type
+class SomeType:
+    id: ID
+    json: JSON
+    base16: Base16
+    base32: Base32
+    base64: Base64
+
+
+obj = SomeType(
+    id=ID("123"),
+    json=JSON({"foo": "bar"}),
+    base16=Base16(b"<bytes>"),
+    base32=Base32(b"<bytes>"),
+    base64=Base64(b"<bytes>"),
+)
+
+reveal_type(obj.id)
+reveal_type(obj.json)
+reveal_type(obj.base16)
+reveal_type(obj.base16)
+reveal_type(obj.base64)
+"""
+
+
+def test_pyright():
+    results = run_pyright(CODE)
+
+    # NOTE: This is also guaranteeing that those scalars could be used to annotate
+    # the attributes. Pyright 1.1.224+ doesn't allow non-types to be used there
+    assert results == [
+        Result(
+            type="information",
+            message='Type of "obj.id" is "ID"',
+            line=23,
+            column=13,
+        ),
+        Result(
+            type="information",
+            message='Type of "obj.json" is "JSON"',
+            line=24,
+            column=13,
+        ),
+        Result(
+            type="information",
+            message='Type of "obj.base16" is "Base16"',
+            line=25,
+            column=13,
+        ),
+        Result(
+            type="information",
+            message='Type of "obj.base16" is "Base16"',
+            line=26,
+            column=13,
+        ),
+        Result(
+            type="information",
+            message='Type of "obj.base64" is "Base64"',
+            line=27,
+            column=13,
+        ),
+    ]
diff --git a/tests/pyright/utils.py b/tests/pyright/utils.py
--- a/tests/pyright/utils.py
+++ b/tests/pyright/utils.py
@@ -11,7 +11,7 @@
 from typing_extensions import Literal
 
 
-ResultType = Literal["error", "info"]
+ResultType = Literal["error", "information"]
 
 
 @dataclass
diff --git a/tests/test_auto.py b/tests/test_auto.py
new file mode 100644
--- /dev/null
+++ b/tests/test_auto.py
@@ -0,0 +1,17 @@
+from typing import Any
+
+from typing_extensions import Annotated, get_args
+
+from strawberry.auto import StrawberryAuto, auto
+
+
+def test_singleton():
+    assert get_args(auto)[1] is StrawberryAuto()
+    assert StrawberryAuto() is StrawberryAuto()
+
+
+def test_annotated():
+    assert get_args(auto) == (Any, StrawberryAuto())
+    some_obj = object()
+    new_annotated = Annotated[auto, some_obj]
+    assert get_args(new_annotated) == (Any, StrawberryAuto(), some_obj)