From 1eb3b5c5d5c842559670acc5065c3483ca7db16f Mon Sep 17 00:00:00 2001 From: Chris O'Hara Date: Tue, 21 May 2024 08:55:13 +1000 Subject: [PATCH 1/4] Use structural typing to decouple the test package from httpx --- examples/auto_retry/test_app.py | 9 +++---- examples/getting_started/test_app.py | 9 +++---- examples/github_stats/test_app.py | 9 +++---- src/dispatch/test/client.py | 25 ++++++++---------- src/dispatch/test/fastapi.py | 10 +++++++ src/dispatch/test/http.py | 30 +++++++++++++++++++++ src/dispatch/test/httpx.py | 39 ++++++++++++++++++++++++++++ tests/test_client.py | 8 +++++- tests/test_fastapi.py | 4 +-- tests/test_http.py | 4 ++- 10 files changed, 114 insertions(+), 33 deletions(-) create mode 100644 src/dispatch/test/fastapi.py create mode 100644 src/dispatch/test/http.py create mode 100644 src/dispatch/test/httpx.py diff --git a/examples/auto_retry/test_app.py b/examples/auto_retry/test_app.py index ba3a440e..fca4dc2f 100644 --- a/examples/auto_retry/test_app.py +++ b/examples/auto_retry/test_app.py @@ -6,11 +6,10 @@ import unittest from unittest import mock -from fastapi.testclient import TestClient - from dispatch import Client from dispatch.sdk.v1 import status_pb2 as status_pb from dispatch.test import DispatchServer, DispatchService, EndpointClient +from dispatch.test.fastapi import http_client class TestAutoRetry(unittest.TestCase): @@ -25,14 +24,14 @@ def test_app(self): from .app import app, dispatch # Setup a fake Dispatch server. - endpoint_client = EndpointClient(TestClient(app)) + app_client = http_client(app) + endpoint_client = EndpointClient(app_client) dispatch_service = DispatchService(endpoint_client, collect_roundtrips=True) with DispatchServer(dispatch_service) as dispatch_server: # Use it when dispatching function calls. dispatch.set_client(Client(api_url=dispatch_server.url)) - http_client = TestClient(app) - response = http_client.get("/") + response = app_client.get("/") self.assertEqual(response.status_code, 200) dispatch_service.dispatch_calls() diff --git a/examples/getting_started/test_app.py b/examples/getting_started/test_app.py index 39e04efa..16a7f8cb 100644 --- a/examples/getting_started/test_app.py +++ b/examples/getting_started/test_app.py @@ -6,10 +6,9 @@ import unittest from unittest import mock -from fastapi.testclient import TestClient - from dispatch import Client from dispatch.test import DispatchServer, DispatchService, EndpointClient +from dispatch.test.fastapi import http_client class TestGettingStarted(unittest.TestCase): @@ -24,14 +23,14 @@ def test_app(self): from .app import app, dispatch # Setup a fake Dispatch server. - endpoint_client = EndpointClient(TestClient(app)) + app_client = http_client(app) + endpoint_client = EndpointClient(app_client) dispatch_service = DispatchService(endpoint_client, collect_roundtrips=True) with DispatchServer(dispatch_service) as dispatch_server: # Use it when dispatching function calls. dispatch.set_client(Client(api_url=dispatch_server.url)) - http_client = TestClient(app) - response = http_client.get("/") + response = app_client.get("/") self.assertEqual(response.status_code, 200) dispatch_service.dispatch_calls() diff --git a/examples/github_stats/test_app.py b/examples/github_stats/test_app.py index 08b7b24f..48440165 100644 --- a/examples/github_stats/test_app.py +++ b/examples/github_stats/test_app.py @@ -6,10 +6,9 @@ import unittest from unittest import mock -from fastapi.testclient import TestClient - from dispatch.function import Client from dispatch.test import DispatchServer, DispatchService, EndpointClient +from dispatch.test.fastapi import http_client class TestGithubStats(unittest.TestCase): @@ -24,14 +23,14 @@ def test_app(self): from .app import app, dispatch # Setup a fake Dispatch server. - endpoint_client = EndpointClient(TestClient(app)) + app_client = http_client(app) + endpoint_client = EndpointClient(app_client) dispatch_service = DispatchService(endpoint_client, collect_roundtrips=True) with DispatchServer(dispatch_service) as dispatch_server: # Use it when dispatching function calls. dispatch.set_client(Client(api_url=dispatch_server.url)) - http_client = TestClient(app) - response = http_client.get("/") + response = app_client.get("/") self.assertEqual(response.status_code, 200) while dispatch_service.queue: diff --git a/src/dispatch/test/client.py b/src/dispatch/test/client.py index 04d2fa9e..617018cd 100644 --- a/src/dispatch/test/client.py +++ b/src/dispatch/test/client.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import Mapping, Optional, Protocol, Union import grpc import httpx @@ -12,6 +12,7 @@ Request, sign_request, ) +from dispatch.test.http import HttpClient class EndpointClient: @@ -24,7 +25,7 @@ class EndpointClient: """ def __init__( - self, http_client: httpx.Client, signing_key: Optional[Ed25519PrivateKey] = None + self, http_client: HttpClient, signing_key: Optional[Ed25519PrivateKey] = None ): """Initialize the client. @@ -32,7 +33,7 @@ def __init__( http_client: Client to use to make HTTP requests. signing_key: Optional Ed25519 private key to use to sign requests. """ - channel = _HttpxGrpcChannel(http_client, signing_key=signing_key) + channel = _HttpGrpcChannel(http_client, signing_key=signing_key) self._stub = function_grpc.FunctionServiceStub(channel) def run(self, request: function_pb.RunRequest) -> function_pb.RunResponse: @@ -46,16 +47,10 @@ def run(self, request: function_pb.RunRequest) -> function_pb.RunResponse: """ return self._stub.Run(request) - @classmethod - def from_url(cls, url: str, signing_key: Optional[Ed25519PrivateKey] = None): - """Returns an EndpointClient for a Dispatch endpoint URL.""" - http_client = httpx.Client(base_url=url) - return EndpointClient(http_client, signing_key) - -class _HttpxGrpcChannel(grpc.Channel): +class _HttpGrpcChannel(grpc.Channel): def __init__( - self, http_client: httpx.Client, signing_key: Optional[Ed25519PrivateKey] = None + self, http_client: HttpClient, signing_key: Optional[Ed25519PrivateKey] = None ): self.http_client = http_client self.signing_key = signing_key @@ -120,9 +115,11 @@ def __call__( wait_for_ready=None, compression=None, ): + url = self.client.url_for(self.method) # note: method==path in gRPC parlance + request = Request( method="POST", - url=str(httpx.URL(self.client.base_url).join(self.method)), + url=url, body=self.request_serializer(request), headers=CaseInsensitiveDict({"Content-Type": "application/grpc+proto"}), ) @@ -131,10 +128,10 @@ def __call__( sign_request(request, self.signing_key, datetime.now()) response = self.client.post( - request.url, content=request.body, headers=request.headers + request.url, body=request.body, headers=request.headers ) response.raise_for_status() - return self.response_deserializer(response.content) + return self.response_deserializer(response.body) def with_call( self, diff --git a/src/dispatch/test/fastapi.py b/src/dispatch/test/fastapi.py new file mode 100644 index 00000000..381b1800 --- /dev/null +++ b/src/dispatch/test/fastapi.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import dispatch.test.httpx +from dispatch.test.client import HttpClient + + +def http_client(app: FastAPI) -> HttpClient: + """Build a client for a FastAPI app.""" + return dispatch.test.httpx.Client(TestClient(app)) diff --git a/src/dispatch/test/http.py b/src/dispatch/test/http.py new file mode 100644 index 00000000..d811a763 --- /dev/null +++ b/src/dispatch/test/http.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from typing import Mapping, Protocol + + +@dataclass +class HttpResponse(Protocol): + status_code: int + body: bytes + + def raise_for_status(self): + """Raise an exception on non-2xx responses.""" + ... + + +class HttpClient(Protocol): + """Protocol for HTTP clients.""" + + def get(self, url: str, headers: Mapping[str, str] = {}) -> HttpResponse: + """Make a GET request.""" + ... + + def post( + self, url: str, body: bytes, headers: Mapping[str, str] = {} + ) -> HttpResponse: + """Make a POST request.""" + ... + + def url_for(self, path: str) -> str: + """Get the fully-qualified URL for a path.""" + ... diff --git a/src/dispatch/test/httpx.py b/src/dispatch/test/httpx.py new file mode 100644 index 00000000..9d9f7c52 --- /dev/null +++ b/src/dispatch/test/httpx.py @@ -0,0 +1,39 @@ +from typing import Mapping + +import httpx + +from dispatch.test.http import HttpClient, HttpResponse + + +class Client(HttpClient): + def __init__(self, client: httpx.Client): + self.client = client + + def get(self, url: str, headers: Mapping[str, str] = {}) -> HttpResponse: + response = self.client.get(url, headers=headers) + return Response(response) + + def post( + self, url: str, body: bytes, headers: Mapping[str, str] = {} + ) -> HttpResponse: + response = self.client.post(url, content=body, headers=headers) + return Response(response) + + def url_for(self, path: str) -> str: + return str(httpx.URL(self.client.base_url).join(path)) + + +class Response(HttpResponse): + def __init__(self, response: httpx.Response): + self.response = response + + @property + def status_code(self): + return self.response.status_code + + @property + def body(self): + return self.response.content + + def raise_for_status(self): + self.response.raise_for_status() diff --git a/tests/test_client.py b/tests/test_client.py index 09f96b59..c04945b2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,6 +2,9 @@ import unittest from unittest import mock +import httpx + +import dispatch.test.httpx from dispatch import Call, Client from dispatch.proto import _any_unpickle as any_unpickle from dispatch.test import DispatchServer, DispatchService, EndpointClient @@ -9,7 +12,10 @@ class TestClient(unittest.TestCase): def setUp(self): - endpoint_client = EndpointClient.from_url("http://function-service") + http_client = dispatch.test.httpx.Client( + httpx.Client(base_url="http://function-service") + ) + endpoint_client = EndpointClient(http_client) api_key = "0000000000000000" self.dispatch_service = DispatchService(endpoint_client, api_key) diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 5c2135dc..dee353fd 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -30,6 +30,7 @@ ) from dispatch.status import Status from dispatch.test import DispatchServer, DispatchService, EndpointClient +from dispatch.test.fastapi import http_client def create_dispatch_instance(app: fastapi.FastAPI, endpoint: str): @@ -44,8 +45,7 @@ def create_dispatch_instance(app: fastapi.FastAPI, endpoint: str): def create_endpoint_client( app: fastapi.FastAPI, signing_key: Optional[Ed25519PrivateKey] = None ): - http_client = TestClient(app) - return EndpointClient(http_client, signing_key) + return EndpointClient(http_client(app), signing_key) class TestFastAPI(unittest.TestCase): diff --git a/tests/test_http.py b/tests/test_http.py index 21e8b099..c5f0c0f4 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -14,6 +14,7 @@ import httpx from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +import dispatch.test.httpx from dispatch.experimental.durable.registry import clear_functions from dispatch.function import Arguments, Error, Function, Input, Output, Registry from dispatch.http import Dispatch @@ -87,7 +88,8 @@ def my_function(input: Input) -> Output: f"You told me: '{input.input}' ({len(input.input)} characters)" ) - client = EndpointClient.from_url(self.endpoint) + http_client = dispatch.test.httpx.Client(httpx.Client(base_url=self.endpoint)) + client = EndpointClient(http_client) pickled = pickle.dumps("Hello World!") input_any = google.protobuf.any_pb2.Any() From 1afdbbd385cd1aeeeb65934a2d824f7cffc119d5 Mon Sep 17 00:00:00 2001 From: Chris O'Hara Date: Tue, 21 May 2024 08:57:16 +1000 Subject: [PATCH 2/4] Remove httpx exception checks in test package We've moved away from the built-in test server in favor of pushing users towards using the CLI. The CLI sets environment variables, so there's no chance of user error which is what these messages were designed to help with. Eliminating the httpx dependency means we can adapt the test package to other web frameworks/clients. --- src/dispatch/test/client.py | 1 - src/dispatch/test/service.py | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/src/dispatch/test/client.py b/src/dispatch/test/client.py index 617018cd..b9f56ab7 100644 --- a/src/dispatch/test/client.py +++ b/src/dispatch/test/client.py @@ -2,7 +2,6 @@ from typing import Mapping, Optional, Protocol, Union import grpc -import httpx from dispatch.sdk.v1 import function_pb2 as function_pb from dispatch.sdk.v1 import function_pb2_grpc as function_grpc diff --git a/src/dispatch/test/service.py b/src/dispatch/test/service.py index 5edf397a..195c4d17 100644 --- a/src/dispatch/test/service.py +++ b/src/dispatch/test/service.py @@ -8,7 +8,6 @@ from typing import Dict, List, Optional, Set, Tuple import grpc -import httpx from typing_extensions import TypeAlias import dispatch.sdk.v1.call_pb2 as call_pb @@ -325,17 +324,6 @@ def _dispatch_continuously(self): try: self.dispatch_calls() - except httpx.HTTPStatusError as e: - if e.response.status_code == 403: - logger.error( - "error dispatching function call to endpoint (403). Is the endpoint's DISPATCH_VERIFICATION_KEY correct?" - ) - else: - logger.exception(e) - except httpx.ConnectError as e: - logger.error( - "error connecting to the endpoint. Is it running and accessible from DISPATCH_ENDPOINT_URL?" - ) except Exception as e: logger.exception(e) From 5c331341789d922cf65787a72f5c7f1c34c326bf Mon Sep 17 00:00:00 2001 From: Chris O'Hara Date: Tue, 21 May 2024 08:58:20 +1000 Subject: [PATCH 3/4] Make httpx a dev dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ea31036e..1fc0d88b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "grpc-stubs >= 1.53.0.5", "http-message-signatures >= 0.4.4", "tblib >= 3.0.0", - "httpx >= 0.27.0", "typing_extensions >= 4.10" ] @@ -24,6 +23,7 @@ fastapi = ["fastapi", "httpx"] lambda = ["awslambdaric"] dev = [ + "httpx >= 0.27.0", "black >= 24.1.0", "isort >= 5.13.2", "mypy >= 1.10.0", From b197b7439f3d1f4af0f9bb71a50e7782b7266e68 Mon Sep 17 00:00:00 2001 From: Chris O'Hara Date: Tue, 21 May 2024 08:58:38 +1000 Subject: [PATCH 4/4] Add a shortcut for flask --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1fc0d88b..e6c03a5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ [project.optional-dependencies] fastapi = ["fastapi", "httpx"] +flask = ["flask"] lambda = ["awslambdaric"] dev = [