Repository: mirumee/ariadne
Pull request #979 (2022-11-23 13:57:24)

Add Python 3.11 to build matrix

codecov[bot]: # [Codecov](https://codecov.io/gh/mirumee/ariadne/pull/979?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) Report
> Merging [#979](https://codecov.io/gh/mirumee/ariadne/pull/979?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) (0517680) into [master](https://codecov.io/gh/mirumee/ariadne/commit/c6a37c90a71c6f7260b7980f89e4f065573f8266?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) (c6a37c9) will **not change** coverage.
> The diff coverage is `n/a`.

```diff
@@           Coverage Diff           @@
##           master     #979   +/-   ##
=======================================
  Coverage   98.40%   98.40%           
=======================================
  Files         103      103           
  Lines        6393     6393           
=======================================
  Hits         6291     6291           
  Misses        102      102           
```



:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee)


Patch:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 243e0c9..6838382 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -13,10 +13,10 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@master
-    - name: Set up Python 3.9
+    - name: Set up Python 3.10
       uses: actions/setup-python@v4
       with:
-        python-version: 3.9
+        python-version: 3.10
     - name: Install pypa/build
       run: >-
         python -m
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dace246..cae3086 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
 - Moved project configuration from `setup.py` to `pyproject.toml`.
 - Added `execution_context_class` option.
 - Added `query_parser` option to ASGI and WSGI `GraphQL` applications that enables query parsing customization.
+- Added Python 3.11 support.
 
 
 ## 0.16.1 (2022-09-26)
diff --git a/ariadne/contrib/tracing/opentracing.py b/ariadne/contrib/tracing/opentracing.py
index c7b3397..bd1dec9 100644
--- a/ariadne/contrib/tracing/opentracing.py
+++ b/ariadne/contrib/tracing/opentracing.py
@@ -1,4 +1,3 @@
-import cgi
 import os
 from functools import partial
 from inspect import isawaitable
@@ -12,6 +11,14 @@ from starlette.datastructures import UploadFile
 from ...types import ContextValue, Extension, Resolver
 from .utils import format_path, should_trace
 
+try:
+    from multipart.multipart import File
+except ImportError:
+
+    class File:  # type: ignore
+        """Mock upload file used when python-multipart is not installed."""
+
+
 ArgFilter = Callable[[Dict[str, Any], GraphQLResolveInfo], Dict[str, Any]]
 
 
@@ -111,21 +118,24 @@ def copy_args_for_tracing(value: Any) -> Any:
         return {k: copy_args_for_tracing(v) for k, v in value.items()}
     if isinstance(value, list):
         return [copy_args_for_tracing(v) for v in value]
-    if isinstance(value, (UploadFile, cgi.FieldStorage)):
+    if isinstance(value, (UploadFile, File)):
         return repr_upload_file(value)
     return value
 
 
-def repr_upload_file(upload_file: Union[UploadFile, cgi.FieldStorage]) -> str:
-    filename = upload_file.filename
+def repr_upload_file(upload_file: Union[UploadFile, File]) -> str:
+    if isinstance(upload_file, File):
+        filename = upload_file.file_name
+    else:
+        filename = upload_file.filename
 
-    if isinstance(upload_file, cgi.FieldStorage):
-        mime_type = upload_file.type
+    if isinstance(upload_file, File):
+        mime_type = "not/available"
     else:
         mime_type = upload_file.content_type
 
-    if upload_file.file is None and isinstance(upload_file, cgi.FieldStorage):
-        size = len(upload_file.value) if upload_file.value is not None else 0
+    if isinstance(upload_file, File):
+        size = upload_file.size
     else:
         file_ = upload_file.file
         file_.seek(0, os.SEEK_END)
diff --git a/ariadne/wsgi.py b/ariadne/wsgi.py
index d84d72b..79fd84e 100644
--- a/ariadne/wsgi.py
+++ b/ariadne/wsgi.py
@@ -1,7 +1,6 @@
 import json
-from cgi import FieldStorage
 from inspect import isawaitable
-from typing import Any, Callable, List, Optional, Union, cast
+from typing import Any, Callable, Dict, List, Optional, Union, cast
 
 from graphql import GraphQLError, GraphQLSchema
 from graphql.execution import Middleware, MiddlewareManager
@@ -31,6 +30,16 @@ from .types import (
     ValidationRules,
 )
 
+try:
+    from multipart import parse_form
+except ImportError:
+
+    def parse_form(*_args, **_kwargs):
+        raise NotImplementedError(
+            "WSGI file uploads requires 'python-multipart' library."
+        )
+
+
 Extensions = Union[
     Callable[[Any, Optional[ContextValue]], ExtensionList], ExtensionList
 ]
@@ -168,9 +177,7 @@ class GraphQL:
 
     def extract_data_from_multipart_request(self, environ: dict) -> Any:
         try:
-            form = FieldStorage(
-                fp=environ["wsgi.input"], environ=environ, keep_blank_values=True
-            )
+            form = parse_multipart_request(environ)
         except (TypeError, ValueError) as ex:
             raise HttpBadRequestError("Malformed request data") from ex
 
@@ -187,7 +194,7 @@ class GraphQL:
                 "Request 'map' multipart field is not a valid JSON"
             ) from ex
 
-        return combine_multipart_data(operations, files_map, form)
+        return combine_multipart_data(operations, files_map, form.files)
 
     def execute_query(self, environ: dict, data: dict) -> GraphQLResult:
         context_value = self.get_context_for_request(environ)
@@ -285,3 +292,54 @@ class GraphQLMiddleware:
         if not environ["PATH_INFO"].startswith(self.path):
             return self.app(environ, start_response)
         return self.graphql_app(environ, start_response)
+
+
+def parse_multipart_request(environ: dict) -> "FormData":
+    content_type = environ.get("CONTENT_TYPE")
+    headers = {"Content-Type": content_type}
+    form_data = FormData(content_type)
+
+    parse_form(
+        headers,
+        environ["wsgi.input"],
+        form_data.on_field,
+        form_data.on_file,
+    )
+
+    return form_data
+
+
+class FormData:
+    charset: str
+    fields: Dict[str, Any]
+    files: Dict[str, Any]
+
+    def __init__(self, content_type: Optional[str]):
+        self.encoding = self.parse_charset(content_type) or "latin-1"
+        self.fields = {}
+        self.files = {}
+
+    def parse_charset(self, content_type: Optional[str]) -> Optional[str]:
+        if not content_type:
+            return None
+
+        if "charset=" not in content_type:
+            return None
+
+        charset = content_type[content_type.index("charset=") + 8 :].strip()
+        if ";" in charset:
+            charset = charset[: charset.index(";")].strip()
+
+        return charset.lower() or None
+
+    def on_field(self, field):
+        field_name = field.field_name.decode(self.encoding)
+        field_value = field.value.decode(self.encoding)
+        self.fields[field_name] = field_value
+
+    def on_file(self, file):
+        field_name = file.field_name.decode(self.encoding)
+        self.files[field_name] = file
+
+    def getvalue(self, field_name: str) -> str:
+        return self.fields.get(field_name, "")
diff --git a/pyproject.toml b/pyproject.toml
index c9c4a1d..770c14f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ classifiers = [
   "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
   "Topic :: Software Development :: Libraries :: Python Modules",
 ]
 dependencies = [


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 43b38ba..10718d9 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -15,7 +15,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.7", "3.8", "3.9", "3.10"]
+        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
 
     steps:
     - uses: actions/checkout@v3
diff --git a/tests/tracing/test_opentracing_sync.py b/tests/tracing/test_opentracing_sync.py
index 8028d72..3cbf006 100644
--- a/tests/tracing/test_opentracing_sync.py
+++ b/tests/tracing/test_opentracing_sync.py
@@ -1,5 +1,3 @@
-import cgi
-import io
 from unittest.mock import ANY, call
 
 import pytest
@@ -13,7 +11,7 @@ from ariadne.contrib.tracing.opentracing import (
 from ariadne.contrib.tracing.opentracing import (
     opentracing_extension_sync as opentracing_extension,
 )
-from ariadne.contrib.tracing.opentracing import copy_args_for_tracing
+from ariadne.contrib.tracing.opentracing import File, copy_args_for_tracing
 
 
 @pytest.fixture
@@ -111,52 +109,50 @@ def test_opentracing_extension_doesnt_break_introspection(schema):
     assert "errors" not in result
 
 
+class MockFile(File):
+    def __init__(self, file_name, size):
+        self._file_name = file_name
+        self._size = size
+
+    @property
+    def size(self):
+        return self._size
+
+
 def test_resolver_args_filter_handles_uploaded_files_from_wsgi(mocker):
     def arg_filter(args, _):
         return args
 
-    file_size = 1024 * 1024
     extension = OpenTracingExtension(arg_filter=arg_filter)
-    field_storage = cgi.FieldStorage()
-    field_storage.filename = "hello.txt"
-    field_storage.type = "text/plain"
-    field_storage.file = io.BytesIO()
-    field_storage.file.write(b"\0" * file_size)
+    test_file = MockFile("hello.txt", 2137)
 
-    kwargs = {"0": field_storage}
+    kwargs = {"0": test_file}
     info = mocker.Mock()
 
     copied_kwargs = extension.filter_resolver_args(kwargs, info)
     assert (
-        f"<class 'cgi.FieldStorage'>"
-        f"(mime_type={field_storage.type}, size={file_size}, filename={field_storage.filename})"
+        "<class 'tests.tracing.test_opentracing_sync.MockFile'>"
+        "(mime_type=not/available, size=2137, filename=hello.txt)"
     ) == copied_kwargs["0"]
 
 
 def test_resolver_args_with_uploaded_files_from_wsgi_are_copied_for_tracing():
-    storage1 = cgi.FieldStorage()
-    storage1.type = "text/plain"
-    storage1.filename = "hello"
-    storage1.value = b"111"
-
-    storage2 = cgi.FieldStorage()
-    storage2.type = "text/plain"
-    storage2.filename = "hi"
-    storage2.value = None
+    file_1 = MockFile("hello.txt", 21)
+    file_2 = MockFile("other.txt", 37)
 
     test_dict = {
         "a": 10,
         "b": [1, 2, 3, {"hehe": {"Hello": 10}}],
-        "c": storage1,
-        "d": {"ee": ["zz", [10, 10, 10], storage2]},
+        "c": file_1,
+        "d": {"ee": ["zz", [10, 10, 10], file_2]},
     }
     result = copy_args_for_tracing(test_dict)
     assert {
         "a": 10,
         "b": [1, 2, 3, {"hehe": {"Hello": 10}}],
         "c": (
-            f"<class 'cgi.FieldStorage'>(mime_type={storage1.type}, "
-            f"size={len(storage1.value)}, filename={storage1.filename})"
+            "<class 'tests.tracing.test_opentracing_sync.MockFile'>"
+            "(mime_type=not/available, size=21, filename=hello.txt)"
         ),
         "d": {
             "ee": [
@@ -167,8 +163,8 @@ def test_resolver_args_with_uploaded_files_from_wsgi_are_copied_for_tracing():
                     10,
                 ],
                 (
-                    f"<class 'cgi.FieldStorage'>(mime_type={storage2.type}, "
-                    f"size=0, filename={storage2.filename})"
+                    "<class 'tests.tracing.test_opentracing_sync.MockFile'>"
+                    "(mime_type=not/available, size=37, filename=other.txt)"
                 ),
             ],
         },
diff --git a/tests/wsgi/factories.py b/tests/wsgi/factories.py
index 9086ae0..4d42000 100644
--- a/tests/wsgi/factories.py
+++ b/tests/wsgi/factories.py
@@ -9,5 +9,5 @@ def create_multipart_request(data):
             "multipart/form-data; boundary=------------------------cec8e8123c05ba25"
         ),
         "CONTENT_LENGTH": len(data),
-        "wsgi.input": BytesIO(data.encode("ascii")),
+        "wsgi.input": BytesIO(data.encode("latin-1")),
     }
diff --git a/tests/wsgi/snapshots/snap_test_query_execution.py b/tests/wsgi/snapshots/snap_test_query_execution.py
index 4253922..7961959 100644
--- a/tests/wsgi/snapshots/snap_test_query_execution.py
+++ b/tests/wsgi/snapshots/snap_test_query_execution.py
@@ -76,7 +76,7 @@ snapshots['test_complex_query_without_operation_name_executes_successfully 1'] =
 }
 
 snapshots['test_query_is_executed_for_multipart_form_request_with_file 1'] = [
-    b'{"data": {"upload": "FieldStorage"}}'
+    b'{"data": {"upload": "File"}}'
 ]
 
 snapshots['test_query_is_executed_for_post_json_request 1'] = {
diff --git a/tests/wsgi/test_query_execution.py b/tests/wsgi/test_query_execution.py
index dff77f0..5b4eccd 100644
--- a/tests/wsgi/test_query_execution.py
+++ b/tests/wsgi/test_query_execution.py
@@ -177,7 +177,9 @@ Content-Type: text/plain
 test
 
 --------------------------cec8e8123c05ba25--
-    """.rstrip()
+    """.rstrip().replace(
+        "\n", "\r\n"
+    )
 
     request = create_multipart_request(data)
     result = middleware(request, start_response)
diff --git a/tests/wsgi/test_request_data_reading.py b/tests/wsgi/test_request_data_reading.py
index 717a119..02d43c7 100644
--- a/tests/wsgi/test_request_data_reading.py
+++ b/tests/wsgi/test_request_data_reading.py
@@ -165,7 +165,9 @@ Content-Type: text/plain
 test
 
 --------------------------cec8e8123c05ba25--
-    """.strip()
+    """.strip().replace(
+        "\n", "\r\n"
+    )
 
     request = create_multipart_request(data)
     result = middleware(request, start_response)
@@ -194,7 +196,9 @@ Content-Type: text/plain
 test
 
 --------------------------cec8e8123c05ba25--
-    """.strip()
+    """.strip().replace(
+        "\n", "\r\n"
+    )
 
     request = create_multipart_request(data)
     result = middleware(request, start_response)