Repository: strawberry-graphql/strawberry

Issue:
Remove `hupper` from debug server
Looks like uvicorn has a reload option that we can use instead of building our own reload mechanism: https://github.com/encode/uvicorn/blob/b5af1049e63c059dc750a450c807b9768f91e906/uvicorn/main.py#L410-L431


Discussion:

Resolving patch:
diff --git a/strawberry/cli/commands/server.py b/strawberry/cli/commands/server.py
--- a/strawberry/cli/commands/server.py
+++ b/strawberry/cli/commands/server.py
@@ -1,10 +1,10 @@
-import importlib
+import os
 import sys
 
 import click
 
 from strawberry import Schema
-from strawberry.asgi import GraphQL
+from strawberry.cli.constants import DEBUG_SERVER_SCHEMA_ENV_VAR_KEY
 from strawberry.utils.importer import import_module_symbol
 
 
@@ -27,10 +27,8 @@ def server(schema, host, port, app_dir):
     sys.path.insert(0, app_dir)
 
     try:
-        import hupper
+        import starlette  # noqa: F401
         import uvicorn
-        from starlette.applications import Starlette
-        from starlette.middleware.cors import CORSMiddleware
     except ImportError:
         message = (
             "The debug server requires additional packages, install them by running:\n"
@@ -48,21 +46,10 @@ def server(schema, host, port, app_dir):
         message = "The `schema` must be an instance of strawberry.Schema"
         raise click.BadArgumentUsage(message)
 
-    reloader = hupper.start_reloader("strawberry.cli.run", verbose=False)
-    schema_module = importlib.import_module(schema_symbol.__module__)
-    reloader.watch_files([schema_module.__file__])
-
-    app = Starlette(debug=True)
-    app.add_middleware(
-        CORSMiddleware, allow_headers=["*"], allow_origins=["*"], allow_methods=["*"]
-    )
-
-    graphql_app = GraphQL(schema_symbol, debug=True)
-
-    paths = ["/", "/graphql"]
-    for path in paths:
-        app.add_route(path, graphql_app)
-        app.add_websocket_route(path, graphql_app)
+    os.environ[DEBUG_SERVER_SCHEMA_ENV_VAR_KEY] = schema
+    app = "strawberry.cli.debug_server:app"
 
     print(f"Running strawberry on http://{host}:{port}/graphql 🍓")
-    uvicorn.run(app, loop="none", host=host, port=port, log_level="error")
+    uvicorn.run(
+        app, host=host, port=port, log_level="error", reload=True, reload_dirs=[app_dir]
+    )
diff --git a/strawberry/cli/constants.py b/strawberry/cli/constants.py
new file mode 100644
--- /dev/null
+++ b/strawberry/cli/constants.py
@@ -0,0 +1 @@
+DEBUG_SERVER_SCHEMA_ENV_VAR_KEY = "STRAWBERRY_DEBUG_SERVER_SCHEMA"
diff --git a/strawberry/cli/debug_server.py b/strawberry/cli/debug_server.py
new file mode 100644
--- /dev/null
+++ b/strawberry/cli/debug_server.py
@@ -0,0 +1,26 @@
+import os
+
+from starlette.applications import Starlette
+from starlette.middleware.cors import CORSMiddleware
+
+from strawberry import Schema
+from strawberry.asgi import GraphQL
+from strawberry.cli.constants import DEBUG_SERVER_SCHEMA_ENV_VAR_KEY
+from strawberry.utils.importer import import_module_symbol
+
+
+app = Starlette(debug=True)
+app.add_middleware(
+    CORSMiddleware, allow_headers=["*"], allow_origins=["*"], allow_methods=["*"]
+)
+
+schema_import_string = os.environ[DEBUG_SERVER_SCHEMA_ENV_VAR_KEY]
+schema_symbol = import_module_symbol(schema_import_string, default_symbol_name="schema")
+
+assert isinstance(schema_symbol, Schema)
+graphql_app = GraphQL(schema_symbol, debug=True)
+
+paths = ["/", "/graphql"]
+for path in paths:
+    app.add_route(path, graphql_app)
+    app.add_websocket_route(path, graphql_app)


Test patch:
diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py
--- a/tests/cli/conftest.py
+++ b/tests/cli/conftest.py
@@ -1,6 +1,9 @@
+import sys
+
 import pytest
 
 from click.testing import CliRunner
+from starlette.testclient import TestClient
 
 
 @pytest.fixture
@@ -8,13 +11,14 @@ def cli_runner(mocker):
     # Mock of uvicorn.run
     uvicorn_run_patch = mocker.patch("uvicorn.run")
     uvicorn_run_patch.return_value = True
-    # Mock to prevent the reloader from kicking in
-    hupper_reloader_patch = mocker.patch("hupper.start_reloader")
-    hupper_reloader_patch.return_value = MockReloader()
     return CliRunner()
 
 
-class MockReloader:
-    @staticmethod
-    def watch_files(*args, **kwargs):
-        return True
+@pytest.fixture
+def debug_server_client(mocker):
+    schema_import_path = "tests.fixtures.sample_package.sample_module"
+    mocker.patch.object(sys, "argv", ["strawberry", "server", schema_import_path])
+
+    from strawberry.cli.debug_server import app
+
+    return TestClient(app)
diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py
--- a/tests/cli/test_server.py
+++ b/tests/cli/test_server.py
@@ -1,8 +1,12 @@
+import os
+import signal
+import subprocess
 import sys
+import time
 
 import pytest
 
-import hupper
+import requests
 import uvicorn
 
 from strawberry.cli.commands.server import server as cmd_server
@@ -11,12 +15,9 @@
 def test_cli_cmd_server(cli_runner):
     schema = "tests.fixtures.sample_package.sample_module"
     result = cli_runner.invoke(cmd_server, [schema])
-    assert result.exit_code == 0
 
-    # We started the reloader
-    assert hupper.start_reloader.call_count == 1
+    assert result.exit_code == 0
     assert uvicorn.run.call_count == 1
-
     assert result.output == "Running strawberry on http://0.0.0.0:8000/graphql 🍓\n"
 
 
@@ -24,12 +25,9 @@ def test_cli_cmd_server_app_dir_option(cli_runner):
     result = cli_runner.invoke(
         cmd_server, ["--app-dir=./tests/fixtures/sample_package", "sample_module"]
     )
-    assert result.exit_code == 0
 
-    # We started the reloader
-    assert hupper.start_reloader.call_count == 1
+    assert result.exit_code == 0
     assert uvicorn.run.call_count == 1
-
     assert result.output == "Running strawberry on http://0.0.0.0:8000/graphql 🍓\n"
 
 
@@ -73,7 +71,7 @@ def test_invalid_schema_instance(cli_runner):
     assert expected_error in result.output
 
 
-@pytest.mark.parametrize("dependency", ["hupper", "uvicorn", "starlette.applications"])
+@pytest.mark.parametrize("dependency", ["uvicorn", "starlette"])
 def test_missing_debug_server_dependencies(cli_runner, mocker, dependency):
     mocker.patch.dict(sys.modules, {dependency: None})
 
@@ -86,3 +84,57 @@ def test_missing_debug_server_dependencies(cli_runner, mocker, dependency):
         "The debug server requires additional packages, install them by running:\n"
         "pip install strawberry-graphql[debug-server]\n"
     )
+
+
+def test_debug_server_routes(debug_server_client):
+    for path in ["/", "/graphql"]:
+        response = debug_server_client.get(path)
+        assert response.status_code == 200
+
+
+def test_automatic_reloading(tmp_path):
+    source = (
+        "import strawberry\n"
+        "@strawberry.type\n"
+        "class Query:\n"
+        "    @strawberry.field\n"
+        "    def number(self) -> int:\n"
+        "        return {}\n"
+        "schema = strawberry.Schema(query=Query)\n"
+    )
+
+    schema_file_path = tmp_path / "schema.py"
+    schema_file_path.touch()
+    schema_file_path.write_text(source.format(42))
+
+    args = ["poetry", "run", "strawberry", "server", "--app-dir", tmp_path, "schema"]
+
+    with subprocess.Popen(
+        args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid
+    ) as proc:
+
+        url = "http://127.0.0.1:8000/graphql"
+        query = {"query": "{ number }"}
+
+        # It takes uvicorn some time to initially start the server
+        for i in range(5):
+            try:
+                response = requests.post(url, json=query)
+                assert response.status_code == 200
+                assert response.json() == {"data": {"number": 42}}
+            except requests.RequestException:
+                time.sleep(0.5)
+
+        schema_file_path.write_text(source.format(1234))
+
+        # It takes uvicorn some time to detect file changes
+        for _ in range(5):
+            try:
+                response = requests.post(url, json=query)
+                assert response.status_code == 200
+                assert response.json() == {"data": {"number": 1234}}
+            except AssertionError:
+                time.sleep(0.5)
+
+        os.killpg(proc.pid, signal.SIGKILL)
+        proc.communicate(timeout=10)