├── tests ├── __init__.py ├── test_sync_client.py ├── test_sync_ws.py ├── test_ws_client.py └── test_client.py ├── .coveragerc ├── asgi_testclient ├── __init__.py ├── types.py ├── sync.py └── client.py ├── mypy.ini ├── .travis.yml ├── pyproject.toml ├── LICENSE ├── .gitignore ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = asgi_testclient/sync.py -------------------------------------------------------------------------------- /asgi_testclient/__init__.py: -------------------------------------------------------------------------------- 1 | from asgi_testclient.client import TestClient, HTTPError, WsDisconnect # noqa 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy-pytest.*] 2 | ignore_missing_imports = True 3 | 4 | [mypy-starlette.*] 5 | ignore_missing_imports = True -------------------------------------------------------------------------------- /asgi_testclient/types.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | 4 | Scope = Dict[str, Any] 5 | Message = Dict[str, Any] 6 | Receive = Callable[[], Awaitable[Message]] 7 | Send = Callable[[Dict[str, Any]], Awaitable[None]] 8 | 9 | 10 | ASGIInstance = Callable[[Receive, Send], Awaitable[None]] 11 | ASGI2App = Callable[[Scope], ASGIInstance] 12 | ASGI3App = Callable[[Scope, Receive, Send], Awaitable[None]] 13 | 14 | Headers = Union[Dict[str, str], List[Tuple[str, str]]] 15 | ReqHeaders = List[Tuple[bytes, bytes]] 16 | ResHeaders = List[Tuple[str, str]] 17 | Params = Union[Dict[str, str], List[Tuple[str, str]]] 18 | Url = Tuple[str, str, int, str, bytes] 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | - "3.8-dev" 7 | 8 | matrix: 9 | allow_failures: 10 | - python: "3.8-dev" 11 | 12 | before_install: 13 | - pip install poetry 14 | - pip install codecov 15 | 16 | install: 17 | - poetry install 18 | 19 | script: 20 | - "poetry run pytest -m 'not sync' --cov-config .coveragerc --cov=asgi_testclient --mypy" 21 | - "poetry run pytest -m sync -v" 22 | 23 | after_script: 24 | - codecov 25 | 26 | before_deploy: 27 | - poetry config http-basic.pypi $PYPI_USER $PYPI_PASSWORD 28 | - poetry build 29 | 30 | deploy: 31 | provider: script 32 | script: poetry publish 33 | skip_cleanup: true 34 | on: 35 | tags: true 36 | branch: master 37 | repo: oldani/asgi-testClient 38 | python: "3.7" -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "asgi-testclient" 3 | version = "0.3.1" 4 | description = "Test Clietn for ASGI web applications" 5 | authors = ["Ordanis Sanchez "] 6 | homepage = "https://github.com/oldani/asgi-testClient" 7 | repository = "https://github.com/oldani/asgi-testClient" 8 | license = "MIT" 9 | readme = "README.md" 10 | classifiers = [ 11 | "Framework :: AsyncIO", 12 | "Topic :: Software Development :: Testing", 13 | "Programming Language :: Python :: 3.6", 14 | "Development Status :: 3 - Alpha" 15 | ] 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.6" 19 | 20 | [tool.poetry.dev-dependencies] 21 | pytest = "^4.3" 22 | pytest-cov = "^2.6" 23 | pytest-mypy = "^0.3.2" 24 | flake8 = "^3.7" 25 | black = "18.9b0" 26 | starlette = "=0.12.9" 27 | pytest-asyncio = "^0.10.0" 28 | python-multipart = "^0.0.5" 29 | 30 | [build-system] 31 | requires = ["poetry>=0.12"] 32 | build-backend = "poetry.masonry.api" 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ordanis Sanchez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_sync_client.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import pytest 3 | from starlette.applications import Starlette 4 | from starlette.responses import JSONResponse 5 | 6 | # from asgi_testclient.sync import TestClient 7 | 8 | 9 | app = Starlette() 10 | 11 | 12 | @app.route( 13 | "/", methods=["GET", "DELETE", "POST", "PUT", "PATCH", "HEAD", "PATCH", "OPTIONS"] 14 | ) 15 | async def index(request): 16 | return JSONResponse({"hello": "world"}) 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def client_class(): 21 | from asgi_testclient.sync import TestClient 22 | 23 | return TestClient 24 | 25 | 26 | @pytest.fixture(scope="module") 27 | def client(client_class): 28 | return client_class(app) 29 | 30 | 31 | @pytest.mark.sync 32 | def test_methods(client): 33 | for method in ["GET", "DELETE", "POST", "PUT", "PATCH", "HEAD", "PATCH", "OPTIONS"]: 34 | meth = getattr(client, method.lower()) 35 | response = meth("/") 36 | assert response.json() == {"hello": "world"} 37 | 38 | 39 | @pytest.mark.sync 40 | @pytest.mark.asyncio 41 | async def test_loop_running(client_class): 42 | with pytest.raises(RuntimeError): 43 | client_class(app) 44 | 45 | 46 | @pytest.mark.sync 47 | @pytest.mark.asyncio 48 | async def test_thread_loop(event_loop, client_class): 49 | def dummy(): 50 | client = client_class(app) 51 | return client.loop.is_running() 52 | 53 | with concurrent.futures.ThreadPoolExecutor() as pool: 54 | result = await event_loop.run_in_executor(pool, dummy) 55 | assert not result 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /tests/test_sync_ws.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.websockets import WebSocket 3 | 4 | 5 | class App: 6 | def __init__(self, scope): 7 | assert scope["type"] == "websocket" 8 | self.scope = scope 9 | 10 | async def __call__(self, receive, send): 11 | websocket = WebSocket(self.scope, receive=receive, send=send) 12 | await websocket.accept() 13 | if websocket.url.path == "/": 14 | await websocket.send_text("Hello, world!") 15 | 16 | if websocket.url.path == "/bytes": 17 | message = await websocket.receive_bytes() 18 | await websocket.send_bytes(message) 19 | 20 | if websocket.url.path == "/json": 21 | message = await websocket.receive_json() 22 | await websocket.send_json(message) 23 | await websocket.close() 24 | 25 | 26 | @pytest.fixture(scope="module") 27 | def client(): 28 | from asgi_testclient.sync import TestClient 29 | 30 | return TestClient(App) 31 | 32 | 33 | @pytest.mark.sync 34 | def test_send_receive_bytes(client): 35 | websocket = client.ws_connect("/bytes") 36 | 37 | byte_msg = b"test" 38 | websocket.send_bytes(byte_msg) 39 | response = websocket.receive_bytes() 40 | 41 | assert response == byte_msg 42 | websocket.close() 43 | 44 | 45 | @pytest.mark.sync 46 | def test_send_receive_json(client): 47 | websocket = client.ws_connect("/json") 48 | 49 | json_msg = {"hello": "test"} 50 | websocket.send_json(json_msg) 51 | response = websocket.receive_json() 52 | 53 | assert response == json_msg 54 | websocket.close() 55 | 56 | 57 | @pytest.mark.sync 58 | def test_ws_context(client): 59 | with client.ws_session("/") as websocket: 60 | data = websocket.receive_text() 61 | assert data == "Hello, world!" 62 | -------------------------------------------------------------------------------- /tests/test_ws_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.websockets import WebSocket, WebSocketDisconnect 3 | 4 | from asgi_testclient import TestClient, WsDisconnect 5 | 6 | 7 | class App: 8 | def __init__(self, scope): 9 | assert scope["type"] == "websocket" 10 | self.scope = scope 11 | 12 | async def __call__(self, receive, send): 13 | websocket = WebSocket(self.scope, receive=receive, send=send) 14 | await websocket.accept() 15 | if websocket.url.path == "/": 16 | await websocket.send_text("Hello, world!") 17 | 18 | if websocket.url.path == "/bytes": 19 | message = await websocket.receive_bytes() 20 | await websocket.send_bytes(message) 21 | 22 | if websocket.url.path == "/json": 23 | message = await websocket.receive_json() 24 | await websocket.send_json(message) 25 | await websocket.close() 26 | 27 | 28 | class Echo: 29 | async def __call__(self, scope, receive, send): 30 | assert scope["type"] == "websocket" 31 | websocket = WebSocket(scope, receive=receive, send=send) 32 | await websocket.accept() 33 | while True: 34 | try: 35 | message = await websocket.receive_text() 36 | except WebSocketDisconnect: 37 | break 38 | await websocket.send_text(message) 39 | 40 | 41 | @pytest.fixture 42 | def client(): 43 | return TestClient(App) 44 | 45 | 46 | @pytest.fixture 47 | def echo_server(): 48 | return TestClient(Echo()) 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_ws(client): 53 | websocket = await client.ws_connect("/") 54 | data = await websocket.receive_text() 55 | assert data == "Hello, world!" 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_ws_disconnect(client): 60 | websocket = await client.ws_connect("/") 61 | await websocket.receive_text() 62 | 63 | with pytest.raises(WsDisconnect): 64 | await websocket.receive_text() 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_send(echo_server): 69 | websocket = await echo_server.ws_connect("/") 70 | for msg in ["Hey", "Echo", "Back"]: 71 | await websocket.send_text(msg) 72 | data = await websocket.receive_text() 73 | assert data == msg 74 | await websocket.close() 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_send_receive_bytes(client): 79 | websocket = await client.ws_connect("/bytes") 80 | 81 | byte_msg = b"test" 82 | await websocket.send_bytes(byte_msg) 83 | response = await websocket.receive_bytes() 84 | 85 | assert response == byte_msg 86 | await websocket.close() 87 | 88 | 89 | @pytest.mark.asyncio 90 | async def test_send_receive_json(client): 91 | websocket = await client.ws_connect("/json") 92 | 93 | json_msg = {"hello": "test"} 94 | await websocket.send_json(json_msg) 95 | response = await websocket.receive_json() 96 | 97 | assert response == json_msg 98 | await websocket.close() 99 | 100 | 101 | @pytest.mark.asyncio 102 | async def test_ws_context(client): 103 | async with client.ws_session("/") as websocket: 104 | data = await websocket.receive_text() 105 | assert data == "Hello, world!" 106 | -------------------------------------------------------------------------------- /asgi_testclient/sync.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from asgi_testclient import client 4 | from asgi_testclient.types import Optional 5 | 6 | 7 | class WsSession(client.WsSession): 8 | def __init__(self, *args): 9 | self._loop = asyncio.get_event_loop() 10 | super().__init__(*args) 11 | 12 | def send_text(self, message: str) -> None: # type: ignore 13 | self._loop.run_until_complete(super().send_text(message)) 14 | 15 | def receive_text(self) -> Optional[str]: # type: ignore 16 | return self._loop.run_until_complete(super().receive_text()) 17 | 18 | def send_bytes(self, message: bytes) -> None: # type: ignore 19 | self._loop.run_until_complete(super().send_bytes(message)) 20 | 21 | def receive_bytes(self) -> Optional[bytes]: # type: ignore 22 | return self._loop.run_until_complete(super().receive_bytes()) 23 | 24 | def send_json(self, message: str) -> None: # type: ignore 25 | _message = {"type": "websocket.receive", "text": json.dumps(message)} 26 | self._loop.run_until_complete(super().send(_message)) 27 | 28 | def receive_json(self): 29 | return self._loop.run_until_complete(super().receive_json()) 30 | 31 | def close(self): 32 | return self._loop.run_until_complete(super().close()) 33 | 34 | 35 | client.WsSession = WsSession # type: ignore 36 | 37 | 38 | class WsContextManager(client.WsContextManager): 39 | def __enter__(self): 40 | return self.ws_session 41 | 42 | def __exit__(self, *args): 43 | return self.ws_session.close() 44 | 45 | 46 | class TestClient(client.TestClient): 47 | def __init__(self, *args, **kwargs) -> None: 48 | super().__init__(*args, **kwargs) 49 | try: 50 | self.loop = asyncio.get_event_loop() 51 | except RuntimeError: # Allow run in threads 52 | self.loop = asyncio.new_event_loop() 53 | asyncio.set_event_loop(self.loop) 54 | 55 | if self.loop.is_running(): # If running is an async app, why use this clas? 56 | raise RuntimeError("Event loop already running. User async client.") 57 | 58 | def get(self, url, **kwargs): 59 | response = self.loop.run_until_complete(self.send("GET", url, **kwargs)) 60 | return response 61 | 62 | def options(self, url, **kwargs): 63 | response = self.loop.run_until_complete(self.send("OPTIONS", url, **kwargs)) 64 | return response 65 | 66 | def head(self, url, **kwargs): 67 | response = self.loop.run_until_complete(self.send("HEAD", url, **kwargs)) 68 | return response 69 | 70 | def post(self, url, data=None, json=None, **kwargs): 71 | response = self.loop.run_until_complete( 72 | self.send("POST", url, data=data, json=json, **kwargs) 73 | ) 74 | return response 75 | 76 | def put(self, url, data=None, **kwargs): 77 | response = self.loop.run_until_complete( 78 | self.send("PUT", url, data=data, **kwargs) 79 | ) 80 | return response 81 | 82 | def delete(self, url, **kwargs): 83 | response = self.loop.run_until_complete(self.send("DELETE", url, **kwargs)) 84 | return response 85 | 86 | def patch(self, url, **kwargs): 87 | response = self.loop.run_until_complete(self.send("PATCH", url, **kwargs)) 88 | return response 89 | 90 | def ws_connect(self, url, subprotocols=None, **kwargs): 91 | websocket = self.loop.run_until_complete( 92 | self.send("GET", url, subprotocols=subprotocols, ws=True, **kwargs) 93 | ) 94 | return websocket 95 | 96 | def ws_session(self, url, subprotocols=None, **kwargs): 97 | ws_session = self.loop.run_until_complete( 98 | self.send("GET", url, subprotocols=subprotocols, ws=True, **kwargs) 99 | ) 100 | return WsContextManager(ws_session) 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # asgi-testClient 2 | [![Build Status](https://travis-ci.org/oldani/asgi-testClient.svg?branch=master)](https://travis-ci.org/oldani/asgi-testClient) 3 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/asgi-testClient.svg) 4 | ![PyPI](https://img.shields.io/pypi/v/asgi-testClient.svg) 5 | [![codecov](https://codecov.io/gh/oldani/asgi-testClient/branch/master/graph/badge.svg)](https://codecov.io/gh/oldani/asgi-testClient) 6 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/asgi-testClient.svg) 7 | [![black](https://img.shields.io/badge/code_style-black-000000.svg)](https://github.com/ambv/black) 8 | 9 | Testing ASGI applications made easy! 10 | 11 | 12 | ## The why? 13 | 14 | **Why** would you build this when all web frameworks come with one? Well, because mostly all those web frameworks have to build their own. I was building my own web framework perhaps (research & learning purpose) and got to the point where a needed a `TestClient` but then a asked my self **why does anybody building web frameworks have to build their own TestClient when there's a standard?**. Ok, then just install `starlette` a use it test client; would you install a library just to use a tiny part of it? **This client does not have any dependencies**. 15 | 16 | ## Requirements 17 | 18 | `Python 3.6+` 19 | 20 | It should run on Python 3.5 but I haven' tested it. 21 | 22 | ## Installation 23 | 24 | `pip install asgi-testclient` 25 | 26 | 27 | ## Usage 28 | 29 | The client replicates the requests API, so if you have used request you should feel comfortable. **Note:** the client method are coroutines `get, post, delete, put, patch, etc..`. 30 | 31 | ```python 32 | import pytest 33 | from asgi_testclient import TestClient 34 | 35 | from myapp import API 36 | 37 | @pytest.fixture 38 | def client(): 39 | return TestClient(API) 40 | 41 | @pytest.mark.asyncio 42 | async def test_get(client): 43 | response = await client.get("/") 44 | assert response.json() == {"hello": "world"} 45 | assert response.status_code == 200 46 | ``` 47 | 48 | I have used `pytest` in this example but you can use whichever runner you prefer. 49 | 50 | If you still prefer simple functions to coroutines, you can use the sync interface: 51 | 52 | ```python 53 | import pytest 54 | from asgi_testclient.sync import TestClient 55 | 56 | @pytest.fixture 57 | def client(): 58 | return TestClient(API) 59 | 60 | def test_get(client): 61 | response = client.get("/") 62 | assert response.json() == {"hello": "world"} 63 | assert response.status_code == 200 64 | ``` 65 | 66 | **Take in account that if you're running inside an async app you should use the async client, yet you can run the sync one inside threads is still desired.** 67 | 68 | 69 | ## Websockets 70 | 71 | If you're using ASGI you may be doing some web-sockets stuff. We have added support for it also, so you can test it easy. 72 | 73 | ```python 74 | from asgi_testclient import TestClient 75 | from myapp import API 76 | 77 | async def test_send(): 78 | echo_server = TestClient(API) 79 | websocket = await echo_server.ws_connect("/") 80 | for msg in ["Hey", "Echo", "Back"]: 81 | await websocket.send_text(msg) 82 | data = await websocket.receive_text() 83 | assert data == msg 84 | await websocket.close() 85 | 86 | async def test_ws_context(): 87 | client = TestClient(API) 88 | async with client.ws_session("/") as websocket: 89 | data = await websocket.receive_text() 90 | assert data == "Hello, world!" 91 | ``` 92 | 93 | Few things to take in count here: 94 | 1. When using `ws_connect` you must call `websocket.close()` to finish up your APP task. 95 | 2. For using websockets in context manager you must use `ws_session` instead of `ws_connect`. 96 | 3. When waiting on server response `websocker.receive_*` it may raise a `WsDisconnect`. 97 | 98 | And one more time for those who don't want to this async we got the sync version:p 99 | 100 | ```python 101 | from asgi_testclient.sync import TestClient 102 | from myapp import API 103 | 104 | client = TestClient(API) 105 | 106 | def test_send_receive_json(): 107 | websocket = client.ws_connect("/json") 108 | 109 | json_msg = {"hello": "test"} 110 | websocket.send_json(json_msg) 111 | 112 | assert websocket.receive_json() == json_msg 113 | websocket.close() 114 | 115 | def test_ws_context(): 116 | with client.ws_session("/") as websocket: 117 | data = websocket.receive_text() 118 | assert data == "Hello, world!" 119 | ``` 120 | 121 | **Important:** In the sync version you cannot use `send` or `receive` since they're coroutines, instead use their children `send_*` or `receive_*` `text|bytes|json`. 122 | 123 | Also sync version is done throw `monkey patching` so you can't use both version `async & sync` at the same time. 124 | 125 | ## TODO: 126 | - [x] Support Websockets client. 127 | - [ ] Cookies support. 128 | - [ ] Redirects. 129 | - [ ] Support files encoding 130 | - [ ] Stream request & response 131 | 132 | 133 | ## Credits 134 | 135 | - `Tom Christie`: I brought inspiration from the `starlette` test client. 136 | - `Kenneth ☤ Reitz`: This package tries to replicate `requests` API. -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.applications import Starlette 3 | from starlette.responses import JSONResponse, PlainTextResponse, StreamingResponse 4 | 5 | from asgi_testclient import TestClient, HTTPError 6 | from asgi_testclient.client import Response 7 | 8 | 9 | app = Starlette() 10 | 11 | 12 | @app.route("/", methods=["GET", "DELETE"]) 13 | async def index(request): 14 | return JSONResponse({"hello": "world"}) 15 | 16 | 17 | @app.route("/text") 18 | async def text(request): 19 | return PlainTextResponse("testing content") 20 | 21 | 22 | @app.route("/headers") 23 | async def headers(request): 24 | return JSONResponse(request.headers.items()) 25 | 26 | 27 | @app.route("/args") 28 | async def args(request): 29 | response = request.query_params._dict # query_params save values in internal _dict 30 | _list = request.query_params.get("list") 31 | if _list: # Test passing same query arg with multiple values 32 | response = request.query_params.getlist(_list) 33 | return JSONResponse(response) 34 | 35 | 36 | @app.route("/json", methods=["POST", "PUT", "PATCH"]) 37 | async def json(request): 38 | return JSONResponse(await request.json()) 39 | 40 | 41 | @app.route("/data", methods=["POST", "PUT", "PATCH"]) 42 | async def data(request): 43 | form = await request.form() 44 | return JSONResponse(form._dict) # form save values in internal _dict 45 | 46 | 47 | @app.route("/stream") 48 | async def strem(request): 49 | async def gen(): 50 | for s in "=" * 10: 51 | yield s 52 | 53 | return StreamingResponse(gen()) 54 | 55 | 56 | @app.route("/server") 57 | async def server(request): 58 | return JSONResponse({"hello": "world"}, status_code=501) 59 | 60 | 61 | @pytest.fixture 62 | def client(): 63 | return TestClient(app) 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_headers(client): 68 | headers = [["X-Token", "test-token"]] 69 | response = await client.get("/headers", headers=headers) 70 | response = dict(response.json()) 71 | 72 | assert headers[0][0] in response 73 | assert headers[0][1] in response.values() 74 | 75 | response = await client.get("/headers", headers=dict(headers)) 76 | response = dict(response.json()) 77 | 78 | assert headers[0][0] in response 79 | assert headers[0][1] in response.values() 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_invalid_headers(client): 84 | headers = "no upported" 85 | with pytest.raises(ValueError): 86 | await client.get("/headers", headers=headers) 87 | 88 | 89 | @pytest.mark.asyncio 90 | async def test_get(client): 91 | response = await client.get("http://test") 92 | assert response.json() == {"hello": "world"} 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_delete(client): 97 | response = await client.delete("/") 98 | assert response.json() == {"hello": "world"} 99 | 100 | 101 | @pytest.mark.asyncio 102 | async def test_get_text(client): 103 | response = await client.get("/text") 104 | assert response.text == "testing content" 105 | 106 | 107 | @pytest.mark.asyncio 108 | async def test_get_content(client): 109 | response = await client.get("/stream") 110 | assert response.content == (b"=" * 10) 111 | 112 | 113 | @pytest.mark.asyncio 114 | async def test_get_args(client): 115 | params = {"name": "test", "age": "1", "space": " str"} 116 | response = await client.get("/args", params=params) 117 | assert response.json() == params 118 | 119 | response = await client.get("/args?name=test2&list=name", params=params) 120 | assert response.json() == ["test2", "test"] 121 | 122 | 123 | @pytest.mark.asyncio 124 | async def test_post_json(client): 125 | json = {"user": "test", "age": "1", "pass": "123456"} 126 | response = await client.post("/json", json=json) 127 | assert response.json() == json 128 | 129 | 130 | @pytest.mark.asyncio 131 | async def test_post_data(client): 132 | data = {"user": "test", "age": "1", "pass": "123456"} 133 | response = await client.post("/data", data=data) 134 | assert response.json() == data 135 | 136 | 137 | @pytest.mark.asyncio 138 | async def test_put_json(client): 139 | json = {"user": "test", "age": "1", "pass": "123456"} 140 | response = await client.put("/json", json=json) 141 | assert response.json() == json 142 | 143 | 144 | @pytest.mark.asyncio 145 | async def test_patch_json(client): 146 | json = {"user": "test", "age": "1", "pass": "123456"} 147 | response = await client.patch("/json", json=json) 148 | assert response.json() == json 149 | 150 | 151 | @pytest.mark.asyncio 152 | async def test_response_ok(client): 153 | response = await client.get("/") 154 | assert response.ok 155 | 156 | response = await client.get("/notfound") 157 | assert not response.ok 158 | 159 | 160 | @pytest.mark.asyncio 161 | async def test_response_raise_for(client): 162 | response = await client.get("/notfound") 163 | 164 | with pytest.raises(HTTPError): 165 | assert not response.raise_for_status() 166 | 167 | response = await client.get("/server") 168 | 169 | with pytest.raises(HTTPError): 170 | assert not response.raise_for_status() 171 | 172 | 173 | @pytest.mark.asyncio 174 | async def test_response_str(client): 175 | response = await client.get("/") 176 | assert str(response) == "" 177 | 178 | 179 | def test_response_invalid_json(): 180 | respose = Response("url", 200, []) 181 | respose.content = b")(_)(_*)(_*9" 182 | 183 | with pytest.raises(ValueError): 184 | respose.json() 185 | 186 | 187 | @pytest.mark.asyncio 188 | async def test_client_raise(): 189 | @app.route("/app/error") 190 | def error(request): 191 | raise ValueError("error") 192 | 193 | client = TestClient(app, raise_server_exceptions=True) 194 | 195 | with pytest.raises(ValueError): 196 | await client.get("/app/error") 197 | 198 | 199 | @pytest.mark.asyncio 200 | async def test_client_bad_scheme(client): 201 | 202 | with pytest.raises(ValueError): 203 | await client.get("noscheme/") 204 | 205 | 206 | @pytest.mark.asyncio 207 | async def test_client_bad_netloc(): 208 | 209 | client = TestClient(app, base_url="http:netloc") 210 | with pytest.raises(ValueError): 211 | await client.get("/nonetloc") 212 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "dev" 3 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 4 | name = "appdirs" 5 | optional = false 6 | python-versions = "*" 7 | version = "1.4.3" 8 | 9 | [[package]] 10 | category = "dev" 11 | description = "Atomic file writes." 12 | name = "atomicwrites" 13 | optional = false 14 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 15 | version = "1.3.0" 16 | 17 | [[package]] 18 | category = "dev" 19 | description = "Classes Without Boilerplate" 20 | name = "attrs" 21 | optional = false 22 | python-versions = "*" 23 | version = "18.2.0" 24 | 25 | [[package]] 26 | category = "dev" 27 | description = "The uncompromising code formatter." 28 | name = "black" 29 | optional = false 30 | python-versions = ">=3.6" 31 | version = "18.9b0" 32 | 33 | [package.dependencies] 34 | appdirs = "*" 35 | attrs = ">=17.4.0" 36 | click = ">=6.5" 37 | toml = ">=0.9.4" 38 | 39 | [[package]] 40 | category = "dev" 41 | description = "Composable command line interface toolkit" 42 | name = "click" 43 | optional = false 44 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 45 | version = "7.0" 46 | 47 | [[package]] 48 | category = "dev" 49 | description = "Cross-platform colored terminal text." 50 | marker = "sys_platform == \"win32\"" 51 | name = "colorama" 52 | optional = false 53 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 54 | version = "0.4.1" 55 | 56 | [[package]] 57 | category = "dev" 58 | description = "Code coverage measurement for Python" 59 | name = "coverage" 60 | optional = false 61 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4" 62 | version = "4.5.2" 63 | 64 | [[package]] 65 | category = "dev" 66 | description = "Discover and load entry points from installed packages." 67 | name = "entrypoints" 68 | optional = false 69 | python-versions = ">=2.7" 70 | version = "0.3" 71 | 72 | [[package]] 73 | category = "dev" 74 | description = "the modular source code checker: pep8, pyflakes and co" 75 | name = "flake8" 76 | optional = false 77 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 78 | version = "3.7.7" 79 | 80 | [package.dependencies] 81 | entrypoints = ">=0.3.0,<0.4.0" 82 | mccabe = ">=0.6.0,<0.7.0" 83 | pycodestyle = ">=2.5.0,<2.6.0" 84 | pyflakes = ">=2.1.0,<2.2.0" 85 | 86 | [[package]] 87 | category = "dev" 88 | description = "McCabe checker, plugin for flake8" 89 | name = "mccabe" 90 | optional = false 91 | python-versions = "*" 92 | version = "0.6.1" 93 | 94 | [[package]] 95 | category = "dev" 96 | description = "More routines for operating on iterables, beyond itertools" 97 | marker = "python_version > \"2.7\"" 98 | name = "more-itertools" 99 | optional = false 100 | python-versions = ">=3.4" 101 | version = "6.0.0" 102 | 103 | [[package]] 104 | category = "dev" 105 | description = "Optional static typing for Python" 106 | name = "mypy" 107 | optional = false 108 | python-versions = "*" 109 | version = "0.670" 110 | 111 | [package.dependencies] 112 | mypy-extensions = ">=0.4.0,<0.5.0" 113 | typed-ast = ">=1.3.1,<1.4.0" 114 | 115 | [[package]] 116 | category = "dev" 117 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 118 | name = "mypy-extensions" 119 | optional = false 120 | python-versions = "*" 121 | version = "0.4.1" 122 | 123 | [[package]] 124 | category = "dev" 125 | description = "plugin and hook calling mechanisms for python" 126 | name = "pluggy" 127 | optional = false 128 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 129 | version = "0.9.0" 130 | 131 | [[package]] 132 | category = "dev" 133 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 134 | name = "py" 135 | optional = false 136 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 137 | version = "1.8.0" 138 | 139 | [[package]] 140 | category = "dev" 141 | description = "Python style guide checker" 142 | name = "pycodestyle" 143 | optional = false 144 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 145 | version = "2.5.0" 146 | 147 | [[package]] 148 | category = "dev" 149 | description = "passive checker of Python programs" 150 | name = "pyflakes" 151 | optional = false 152 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 153 | version = "2.1.0" 154 | 155 | [[package]] 156 | category = "dev" 157 | description = "pytest: simple powerful testing with Python" 158 | name = "pytest" 159 | optional = false 160 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 161 | version = "4.3.0" 162 | 163 | [package.dependencies] 164 | atomicwrites = ">=1.0" 165 | attrs = ">=17.4.0" 166 | colorama = "*" 167 | pluggy = ">=0.7" 168 | py = ">=1.5.0" 169 | setuptools = "*" 170 | six = ">=1.10.0" 171 | 172 | [package.dependencies.more-itertools] 173 | python = ">2.7" 174 | version = ">=4.0.0" 175 | 176 | [[package]] 177 | category = "dev" 178 | description = "Pytest support for asyncio." 179 | name = "pytest-asyncio" 180 | optional = false 181 | python-versions = ">= 3.5" 182 | version = "0.10.0" 183 | 184 | [package.dependencies] 185 | pytest = ">=3.0.6" 186 | 187 | [[package]] 188 | category = "dev" 189 | description = "Pytest plugin for measuring coverage." 190 | name = "pytest-cov" 191 | optional = false 192 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 193 | version = "2.6.1" 194 | 195 | [package.dependencies] 196 | coverage = ">=4.4" 197 | pytest = ">=3.6" 198 | 199 | [[package]] 200 | category = "dev" 201 | description = "Mypy static type checker plugin for Pytest" 202 | name = "pytest-mypy" 203 | optional = false 204 | python-versions = "*" 205 | version = "0.3.2" 206 | 207 | [package.dependencies] 208 | mypy = ">=0.570,<1.0" 209 | pytest = ">=2.9.2" 210 | 211 | [[package]] 212 | category = "dev" 213 | description = "A streaming multipart parser for Python" 214 | name = "python-multipart" 215 | optional = false 216 | python-versions = "*" 217 | version = "0.0.5" 218 | 219 | [package.dependencies] 220 | six = ">=1.4.0" 221 | 222 | [[package]] 223 | category = "dev" 224 | description = "Python 2 and 3 compatibility utilities" 225 | name = "six" 226 | optional = false 227 | python-versions = ">=2.6, !=3.0.*, !=3.1.*" 228 | version = "1.12.0" 229 | 230 | [[package]] 231 | category = "dev" 232 | description = "The little ASGI library that shines." 233 | name = "starlette" 234 | optional = false 235 | python-versions = ">=3.6" 236 | version = "0.12.9" 237 | 238 | [[package]] 239 | category = "dev" 240 | description = "Python Library for Tom's Obvious, Minimal Language" 241 | name = "toml" 242 | optional = false 243 | python-versions = "*" 244 | version = "0.10.0" 245 | 246 | [[package]] 247 | category = "dev" 248 | description = "a fork of Python 2 and 3 ast modules with type comment support" 249 | name = "typed-ast" 250 | optional = false 251 | python-versions = "*" 252 | version = "1.3.1" 253 | 254 | [metadata] 255 | content-hash = "495c453143fc32cbcd0ae563a530167b53ffa065f8a731bd3471a4e9ec38c9f1" 256 | python-versions = "^3.6" 257 | 258 | [metadata.hashes] 259 | appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] 260 | atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] 261 | attrs = ["10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", "ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"] 262 | black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] 263 | click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] 264 | colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] 265 | coverage = ["06123b58a1410873e22134ca2d88bd36680479fe354955b3579fb8ff150e4d27", "09e47c529ff77bf042ecfe858fb55c3e3eb97aac2c87f0349ab5a7efd6b3939f", "0a1f9b0eb3aa15c990c328535655847b3420231af299386cfe5efc98f9c250fe", "0cc941b37b8c2ececfed341444a456912e740ecf515d560de58b9a76562d966d", "0d34245f824cc3140150ab7848d08b7e2ba67ada959d77619c986f2062e1f0e8", "10e8af18d1315de936d67775d3a814cc81d0747a1a0312d84e27ae5610e313b0", "1b4276550b86caa60606bd3572b52769860a81a70754a54acc8ba789ce74d607", "1e8a2627c48266c7b813975335cfdea58c706fe36f607c97d9392e61502dc79d", "258b21c5cafb0c3768861a6df3ab0cfb4d8b495eee5ec660e16f928bf7385390", "2b224052bfd801beb7478b03e8a66f3f25ea56ea488922e98903914ac9ac930b", "3ad59c84c502cd134b0088ca9038d100e8fb5081bbd5ccca4863f3804d81f61d", "447c450a093766744ab53bf1e7063ec82866f27bcb4f4c907da25ad293bba7e3", "46101fc20c6f6568561cdd15a54018bb42980954b79aa46da8ae6f008066a30e", "4710dc676bb4b779c4361b54eb308bc84d64a2fa3d78e5f7228921eccce5d815", "510986f9a280cd05189b42eee2b69fecdf5bf9651d4cd315ea21d24a964a3c36", "5535dda5739257effef56e49a1c51c71f1d37a6e5607bb25a5eee507c59580d1", "5a7524042014642b39b1fcae85fb37556c200e64ec90824ae9ecf7b667ccfc14", "5f55028169ef85e1fa8e4b8b1b91c0b3b0fa3297c4fb22990d46ff01d22c2d6c", "6694d5573e7790a0e8d3d177d7a416ca5f5c150742ee703f3c18df76260de794", "6831e1ac20ac52634da606b658b0b2712d26984999c9d93f0c6e59fe62ca741b", "71afc1f5cd72ab97330126b566bbf4e8661aab7449f08895d21a5d08c6b051ff", "7349c27128334f787ae63ab49d90bf6d47c7288c63a0a5dfaa319d4b4541dd2c", "77f0d9fa5e10d03aa4528436e33423bfa3718b86c646615f04616294c935f840", "828ad813c7cdc2e71dcf141912c685bfe4b548c0e6d9540db6418b807c345ddd", "859714036274a75e6e57c7bab0c47a4602d2a8cfaaa33bbdb68c8359b2ed4f5c", "85a06c61598b14b015d4df233d249cd5abfa61084ef5b9f64a48e997fd829a82", "869ef4a19f6e4c6987e18b315721b8b971f7048e6eaea29c066854242b4e98d9", "8cb4febad0f0b26c6f62e1628f2053954ad2c555d67660f28dfb1b0496711952", "977e2d9a646773cc7428cdd9a34b069d6ee254fadfb4d09b3f430e95472f3cf3", "99bd767c49c775b79fdcd2eabff405f1063d9d959039c0bdd720527a7738748a", "a5c58664b23b248b16b96253880b2868fb34358911400a7ba39d7f6399935389", "aaa0f296e503cda4bc07566f592cd7a28779d433f3a23c48082af425d6d5a78f", "ab235d9fe64833f12d1334d29b558aacedfbca2356dfb9691f2d0d38a8a7bfb4", "b3b0c8f660fae65eac74fbf003f3103769b90012ae7a460863010539bb7a80da", "bab8e6d510d2ea0f1d14f12642e3f35cefa47a9b2e4c7cea1852b52bc9c49647", "c45297bbdbc8bb79b02cf41417d63352b70bcb76f1bbb1ee7d47b3e89e42f95d", "d19bca47c8a01b92640c614a9147b081a1974f69168ecd494687c827109e8f42", "d64b4340a0c488a9e79b66ec9f9d77d02b99b772c8b8afd46c1294c1d39ca478", "da969da069a82bbb5300b59161d8d7c8d423bc4ccd3b410a9b4d8932aeefc14b", "ed02c7539705696ecb7dc9d476d861f3904a8d2b7e894bd418994920935d36bb", "ee5b8abc35b549012e03a7b1e86c09491457dba6c94112a2482b18589cc2bdb9"] 266 | entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] 267 | flake8 = ["859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", "a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"] 268 | mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] 269 | more-itertools = ["0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40", "590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1"] 270 | mypy = ["308c274eb8482fbf16006f549137ddc0d69e5a589465e37b99c4564414363ca7", "e80fd6af34614a0e898a57f14296d0dacb584648f0339c2e000ddbf0f4cc2f8d"] 271 | mypy-extensions = ["37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812", "b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e"] 272 | pluggy = ["19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", "84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"] 273 | py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] 274 | pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] 275 | pyflakes = ["5e8c00e30c464c99e0b501dc160b13a14af7f27d4dffb529c556e30a159e231d", "f277f9ca3e55de669fba45b7393a1449009cff5a37d1af10ebb76c52765269cd"] 276 | pytest = ["067a1d4bf827ffdd56ad21bd46674703fce77c5957f6c1eef731f6146bfcef1c", "9687049d53695ad45cf5fdc7bbd51f0c49f1ea3ecfc4b7f3fde7501b541f17f4"] 277 | pytest-asyncio = ["9fac5100fd716cbecf6ef89233e8590a4ad61d729d1732e0a96b84182df1daaf", "d734718e25cfc32d2bf78d346e99d33724deeba774cc4afdf491530c6184b63b"] 278 | pytest-cov = ["0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33", "230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f"] 279 | pytest-mypy = ["8f6436eed8118afd6c10a82b3b60fb537336736b0fd7a29262a656ac42ce01ac", "acc653210e7d8d5c72845a5248f00fd33f4f3379ca13fe56cfc7b749b5655c3e"] 280 | python-multipart = ["f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"] 281 | six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] 282 | starlette = ["c2ac9a42e0e0328ad20fe444115ac5e3760c1ee2ac1ff8cdb5ec915c4a453411"] 283 | toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] 284 | typed-ast = ["035a54ede6ce1380599b2ce57844c6554666522e376bd111eb940fbc7c3dad23", "037c35f2741ce3a9ac0d55abfcd119133cbd821fffa4461397718287092d9d15", "049feae7e9f180b64efacbdc36b3af64a00393a47be22fa9cb6794e68d4e73d3", "19228f7940beafc1ba21a6e8e070e0b0bfd1457902a3a81709762b8b9039b88d", "2ea681e91e3550a30c2265d2916f40a5f5d89b59469a20f3bad7d07adee0f7a6", "3a6b0a78af298d82323660df5497bcea0f0a4a25a0b003afd0ce5af049bd1f60", "5385da8f3b801014504df0852bf83524599df890387a3c2b17b7caa3d78b1773", "606d8afa07eef77280c2bf84335e24390055b478392e1975f96286d99d0cb424", "69245b5b23bbf7fb242c9f8f08493e9ecd7711f063259aefffaeb90595d62287", "6f6d839ab09830d59b7fa8fb6917023d8cb5498ee1f1dbd82d37db78eb76bc99", "730888475f5ac0e37c1de4bd05eeb799fdb742697867f524dc8a4cd74bcecc23", "9819b5162ffc121b9e334923c685b0d0826154e41dfe70b2ede2ce29034c71d8", "9e60ef9426efab601dd9aa120e4ff560f4461cf8442e9c0a2b92548d52800699", "af5fbdde0690c7da68e841d7fc2632345d570768ea7406a9434446d7b33b0ee1", "b64efdbdf3bbb1377562c179f167f3bf301251411eb5ac77dec6b7d32bcda463", "bac5f444c118aeb456fac1b0b5d14c6a71ea2a42069b09c176f75e9bd4c186f6", "bda9068aafb73859491e13b99b682bd299c1b5fd50644d697533775828a28ee0", "d659517ca116e6750101a1326107d3479028c5191f0ecee3c7203c50f5b915b0", "eddd3fb1f3e0f82e5915a899285a39ee34ce18fd25d89582bc89fc9fb16cd2c6"] 285 | -------------------------------------------------------------------------------- /asgi_testclient/client.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json as _json 3 | from asyncio import Queue, ensure_future, sleep 4 | from http import HTTPStatus 5 | from urllib.parse import urlsplit, urlencode 6 | from wsgiref.headers import Headers as _Headers 7 | 8 | from asgi_testclient.types import ( 9 | Scope, 10 | Receive, 11 | Send, 12 | ASGI2App, 13 | ASGI3App, 14 | Message, 15 | Headers, 16 | Params, 17 | Url, 18 | ReqHeaders, 19 | ResHeaders, 20 | Optional, 21 | List, 22 | Union, 23 | cast 24 | ) 25 | 26 | DEFAULT_PORTS = {"http": 80, "ws": 80, "https": 443, "wss": 443} 27 | 28 | 29 | class HTTPError(Exception): 30 | pass 31 | 32 | 33 | class WsDisconnect(Exception): 34 | pass 35 | 36 | 37 | def is_asgi2(app: Union[ASGI2App, ASGI3App]) -> bool: 38 | if inspect.isclass(app): 39 | return True 40 | 41 | if hasattr(app, "__call__") and inspect.iscoroutinefunction(app.__call__): #type: ignore 42 | return False 43 | 44 | return not inspect.iscoroutinefunction(app) 45 | 46 | 47 | class ASGI2to3: 48 | def __init__(self, app: ASGI2App) -> None: 49 | self.app = app 50 | 51 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 52 | instance = self.app(scope) 53 | await instance(receive, send) 54 | 55 | 56 | class Response: 57 | """ 58 | TODO: Allow Response object to act as stream 59 | """ 60 | 61 | def __init__(self, url: str, status_code: int, headers: ResHeaders) -> None: 62 | self.url = url 63 | self.status_code = status_code 64 | self.reason = HTTPStatus(status_code).phrase 65 | self.headers: _Headers = _Headers(headers) 66 | self._content: bytes = b"" 67 | 68 | def __repr__(self): 69 | return f"" 70 | 71 | def raise_for_status(self) -> None: 72 | """ Raises `HTTPError`, if one occurred. """ 73 | if 400 <= self.status_code < 500: 74 | raise HTTPError( 75 | f"{self.status_code} Client Error: {self.reason} for url: {self.url}" 76 | ) 77 | 78 | elif 500 <= self.status_code < 600: 79 | raise HTTPError( 80 | f"{self.status_code} Server Error: {self.reason} for url: {self.url}" 81 | ) 82 | 83 | @property 84 | def ok(self) -> bool: 85 | """ Returns True if :attr:`status_code` is less than 400, False if not. 86 | 87 | This attribute checks if the status code of the response is between 88 | 400 and 600 to see if there was a client error or a server error. If 89 | the status code is between 200 and 400, this will return True. 90 | 91 | This is **not** a check to see if the response code is ``200 OK``. """ 92 | try: 93 | self.raise_for_status() 94 | except HTTPError: 95 | return False 96 | return True 97 | 98 | @property 99 | def content(self) -> bytes: 100 | """ Content of the response, in bytes. """ 101 | return self._content 102 | 103 | @content.setter 104 | def content(self, content: bytes): 105 | """ Allow streaming response by appending content. """ 106 | if self._content: 107 | self._content += content 108 | else: 109 | self._content = content 110 | 111 | @property 112 | def text(self): 113 | """ Content of the response, in unicode. """ 114 | return self.content.decode() 115 | 116 | def json(self, **kwargs): 117 | """ Returns the json-encoded content of a response, if any. 118 | 119 | :param **kwargs: Optional arguments that ``json.loads`` takes. 120 | :raises ValueError: If the response body does not contain valid json. """ 121 | try: 122 | json = _json.loads(self.text, **kwargs) 123 | return json 124 | except _json.JSONDecodeError: 125 | raise ValueError( 126 | f"Response content is not JSON serializable. Text {self.text}" 127 | ) 128 | 129 | 130 | class WsSession: 131 | def __init__(self, app: ASGI3App, scope: Scope) -> None: 132 | self._client: Queue = Queue() # For ASGI app to send messages 133 | self._server: Queue = Queue() # For client session to send message to ASGI app 134 | 135 | self._server_task = ensure_future( 136 | app(scope, self._server_receive, self._server_send) 137 | ) 138 | 139 | async def _start(self) -> None: 140 | """ Start conmunication between client and ASGI app. """ 141 | await self.send({"type": "websocket.connect"}) 142 | await self.receive() 143 | 144 | async def _server_send(self, message: Message) -> None: 145 | """ Put a message in client queue where it can consume. """ 146 | await self._client.put(message) 147 | 148 | async def _server_receive(self) -> Message: 149 | """ Read message from client. """ 150 | return await self._server.get() 151 | 152 | async def send(self, message: Message) -> None: 153 | """ Put message on ASGI app queue where it can consume it. """ 154 | await self._server.put(message) 155 | 156 | async def receive(self) -> Message: 157 | """ Read message from ASGI app. """ 158 | message = await self._client.get() 159 | if message["type"] == "websocket.close": 160 | raise WsDisconnect 161 | return message 162 | 163 | async def send_text(self, message: str) -> None: 164 | await self.send({"type": "websocket.receive", "text": message}) 165 | 166 | async def receive_text(self) -> Optional[str]: 167 | message = await self.receive() 168 | return message.get("text") 169 | 170 | async def send_bytes(self, message: bytes) -> None: 171 | await self.send({"type": "websocket.receive", "bytes": message}) 172 | 173 | async def receive_bytes(self) -> Optional[bytes]: 174 | message = await self.receive() 175 | return message.get("bytes") 176 | 177 | async def send_json(self, message: str) -> None: 178 | message = _json.dumps(message) 179 | await self.send_text(message) 180 | 181 | async def receive_json(self): 182 | message = (await self.receive()).get("text") 183 | return _json.loads(message) 184 | 185 | async def close(self): 186 | """ Finish session with server, wait until handler is done. """ 187 | await self.send({"type": "websocket.disconnect", "code": 1000}) 188 | while not self._server_task.done(): 189 | await sleep(0.1) 190 | 191 | 192 | class WsContextManager: 193 | def __init__(self, ws_session): 194 | self.ws_session = ws_session 195 | 196 | async def __aenter__(self): 197 | self.ws_session = await self.ws_session 198 | return self.ws_session 199 | 200 | async def __aexit__(self, *args): 201 | await self.ws_session.close() 202 | 203 | 204 | class TestClient: 205 | """ 206 | Client for testing ASGI applications. 207 | Mimics ASGI server parsing requests and responses, replicating requests API. 208 | TODO: 209 | - Support Websockets client. 210 | - Cookies support. 211 | - Redirects. """ 212 | 213 | __test__ = False # For pytest 214 | default_headers: list = [ 215 | (b"user-agent", b"testclient"), 216 | (b"accept-encoding", b"gzip, deflate"), 217 | (b"accept", b"*/*"), 218 | (b"connection", b"keep-alive"), 219 | ] 220 | 221 | def __init__( 222 | self, 223 | app: Union[ASGI2App, ASGI3App], 224 | raise_server_exceptions: bool = True, 225 | base_url: str = "http://testserver", 226 | ) -> None: 227 | 228 | if is_asgi2(app): 229 | app = cast(ASGI2App, app) 230 | app = ASGI2to3(app) 231 | self.app = cast(ASGI3App, app) 232 | else: 233 | self.app = cast(ASGI3App, app) 234 | self.base_url = base_url 235 | self.raise_server_exceptions = raise_server_exceptions 236 | 237 | async def send( 238 | self, 239 | method: str, 240 | url: str, 241 | params: Params = {}, 242 | data: dict = {}, 243 | headers: Headers = {}, 244 | json: dict = {}, 245 | subprotocols: Optional[List[str]] = None, 246 | ws: bool = False, 247 | ) -> Union[Response, WsSession]: 248 | """ Handle request/response cycle seting up request, creating scope dict, 249 | calling the app and awaiting in the handler to return the response. """ 250 | self.url = url 251 | scheme, host, port, path, query = self.prepare_url(url, params=params) 252 | req_headers: ReqHeaders = self.prepare_headers(host, headers) 253 | 254 | scope = { 255 | "http_version": "1.1", 256 | "method": method, 257 | "path": path, 258 | "root_path": "", 259 | "scheme": scheme, 260 | "query_string": query, 261 | "headers": req_headers, 262 | "client": ("testclient", 5000), 263 | "server": [host, port], 264 | } 265 | 266 | if ws: 267 | scope["type"] = "websocket" 268 | scope["scheme"] = "ws" 269 | scope["subprotocols"] = subprotocols or [] 270 | session = WsSession(self.app, scope) 271 | await session._start() 272 | return session 273 | 274 | scope["type"] = "http" 275 | self.prepare_body(req_headers, data=data, json=json) 276 | try: 277 | self.__response_started = False 278 | self.__response_complete = False 279 | await self.app(scope, self._receive, self._send) 280 | except Exception as ex: 281 | if self.raise_server_exceptions: 282 | raise ex from None 283 | return self._response 284 | 285 | def prepare_url(self, url: str, params: Params) -> Url: 286 | """ Parse url and query params, run validation. 287 | return: 288 | - scheme: (http or https) 289 | - host: (IP or domain) 290 | - port: (Custon port, or default) 291 | - path: (Quoted url path) 292 | - query: (Encoded query) 293 | """ 294 | if url.startswith("/"): 295 | url = f"{self.base_url}{url}" 296 | scheme, netloc, path, query, _ = urlsplit(url) 297 | 298 | if not scheme: 299 | raise ValueError( 300 | f"Invalid URL. No scheme supplied. Perhaps you meant http://{url}" 301 | ) 302 | elif not netloc: 303 | raise ValueError(f"Invalid URL {url}. No host supplied") 304 | 305 | if not path: 306 | path = "/" 307 | 308 | host: str 309 | port: int 310 | if ":" in netloc: 311 | host, sport = netloc.split(":") 312 | port = int(sport) 313 | else: 314 | host, port = netloc, DEFAULT_PORTS.get(scheme, 80) 315 | 316 | # Query Params 317 | if params: 318 | if isinstance(params, (dict, list)): 319 | q = urlencode(params) 320 | if query: 321 | query = f"{query}&{q}" 322 | else: 323 | query = q 324 | 325 | return scheme, host, port, path, query.encode() 326 | 327 | def prepare_headers(self, host: str, headers: Headers = []) -> ReqHeaders: 328 | """ Prepares the given HTTP headers.""" 329 | _headers: list = [(b"host", host.encode())] 330 | _headers += self.default_headers 331 | 332 | if headers: 333 | if isinstance(headers, dict): 334 | _headers += [ 335 | (k.encode(), v.encode()) for k, v in headers.items() 336 | ] 337 | elif isinstance(headers, list): 338 | _headers += [(k.encode(), v.encode()) for k, v in headers] 339 | else: 340 | raise ValueError("Headers must be Dict or List objects") 341 | return _headers 342 | 343 | def prepare_body( 344 | self, headers: ReqHeaders, data: dict = {}, json: dict = {} 345 | ) -> None: 346 | """ Prepares the given HTTP body data. 347 | TODO: Support files encoding 348 | """ 349 | self._body: bytes = b"" 350 | if not data and json: 351 | headers.append((b"content-type", b"application/json")) 352 | self._body = _json.dumps(json).encode() 353 | elif data: 354 | self._body = urlencode(data, doseq=True).encode() 355 | headers.append( 356 | (b"content-type", b"application/x-www-form-urlencoded") 357 | ) 358 | headers.append((b"content-length", str(len(self._body)).encode())) 359 | 360 | async def _send(self, message: Message) -> None: 361 | """ Mimic ASGI send awaitable, create and set response object. """ 362 | if message["type"] == "http.response.start": 363 | assert ( 364 | not self.__response_started 365 | ), 'Received multiple "http.response.start" messages.' 366 | self._response = Response( 367 | self.url, 368 | status_code=message["status"], 369 | headers=[ 370 | (k.decode(), v.decode()) for k, v in message["headers"] 371 | ], 372 | ) 373 | self.__response_started = True 374 | elif message["type"] == "http.response.body": 375 | assert ( 376 | self.__response_started 377 | ), 'Received "http.response.body" without "http.response.start".' 378 | assert ( 379 | not self.__response_complete 380 | ), 'Received "http.response.body" after response completed.' 381 | self._response.content = message.get("body", b"") 382 | if not message.get("more_body", False): 383 | self.__response_complete = True 384 | 385 | async def _receive(self) -> Message: 386 | """ Mimic ASGI receive awaitable. 387 | TODO: Mimic Stream requests 388 | """ 389 | return {"type": "http.request", "body": self._body, "more_body": False} 390 | 391 | async def get(self, url, **kwargs): 392 | return await self.send("GET", url, **kwargs) 393 | 394 | async def options(self, url, **kwargs): 395 | return await self.send("OPTIONS", url, **kwargs) 396 | 397 | async def head(self, url, **kwargs): 398 | return await self.send("HEAD", url, **kwargs) 399 | 400 | async def post(self, url, data=None, json=None, **kwargs): 401 | return await self.send("POST", url, data=data, json=json, **kwargs) 402 | 403 | async def put(self, url, data=None, **kwargs): 404 | return await self.send("PUT", url, data=data, **kwargs) 405 | 406 | async def delete(self, url, **kwargs): 407 | return await self.send("DELETE", url, **kwargs) 408 | 409 | async def patch(self, url, **kwargs): 410 | return await self.send("PATCH", url, **kwargs) 411 | 412 | async def ws_connect(self, url, subprotocols=None, **kwargs): 413 | return await self.send( 414 | "GET", url, subprotocols=subprotocols, ws=True, **kwargs 415 | ) 416 | 417 | def ws_session(self, url, subprotocols=None, **kwargs): 418 | return WsContextManager( 419 | self.send("GET", url, subprotocols=subprotocols, ws=True, **kwargs) 420 | ) 421 | --------------------------------------------------------------------------------