├── docs ├── _templates │ └── breadcrumbs.html ├── Makefile ├── make.bat ├── start.rst ├── asgi.rst ├── index.rst ├── testutils.rst ├── conf.py ├── reference.rst └── guide.rst ├── .readthedocs.yml ├── asgineer ├── __init__.py ├── _compat.py ├── _run.py ├── utils.py ├── _app.py ├── _request.py └── testutils.py ├── examples ├── example_middleware.py ├── example_request_info.py ├── example_assets.py ├── example_ws_echo.py ├── example_ws_chat.py ├── example_http.py └── example_chat.py ├── tests ├── test_run.py ├── test_meta.py ├── test_request.py ├── common.py ├── test_app.py ├── test_unixsocket.py ├── test_testutils.py ├── test_utils.py ├── test_http_long.py ├── test_websocket.py └── test_http.py ├── pyproject.toml ├── LICENSE ├── .gitignore ├── README.md └── .github └── workflows └── ci.yml /docs/_templates/breadcrumbs.html: -------------------------------------------------------------------------------- 1 | {%- extends "sphinx_rtd_theme/breadcrumbs.html" %} 2 | 3 | {% block breadcrumbs_aside %} 4 |
  • 5 | View code on Github 6 |
  • 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-lts-latest 8 | tools: 9 | python: "3.13" 10 | 11 | sphinx: 12 | configuration: docs/conf.py 13 | fail_on_warning: true 14 | 15 | python: 16 | install: 17 | - method: pip 18 | path: . 19 | extra_requirements: 20 | - docs 21 | -------------------------------------------------------------------------------- /asgineer/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Asgineer - A really thin ASGI web framework 3 | """ 4 | 5 | from ._request import BaseRequest, HttpRequest, WebsocketRequest 6 | from ._request import RequestSet, DisconnectedError 7 | from ._app import to_asgi 8 | from ._run import run 9 | from . import utils 10 | from .utils import sleep 11 | 12 | 13 | __all__ = [ 14 | "BaseRequest", 15 | "DisconnectedError", 16 | "HttpRequest", 17 | "RequestSet", 18 | "WebsocketRequest", 19 | "run", 20 | "sleep", 21 | "to_asgi", 22 | "utils", 23 | ] 24 | 25 | 26 | __version__ = "0.9.4" 27 | -------------------------------------------------------------------------------- /examples/example_middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Demonstrate the use of Starlette middleware to Asgineer handlers. 3 | 4 | Because Asgineer produces an ASGI-compatible application class, we can 5 | wrap it with ASGI middleware, e.g. from Starlette. Hooray for standards! 6 | """ 7 | 8 | import asgineer 9 | 10 | from starlette.middleware.gzip import GZipMiddleware 11 | 12 | 13 | @asgineer.to_asgi 14 | async def main(req): 15 | return "hello world " * 1000 16 | 17 | 18 | # All requests that have a body over 1 KiB will be zipped 19 | main = GZipMiddleware(main, minimum_size=1024) 20 | 21 | 22 | if __name__ == "__main__": 23 | asgineer.run("__main__:main", "uvicorn") 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/test_run.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test some specifics of the run function. 3 | """ 4 | 5 | import asgineer 6 | import pytest 7 | 8 | 9 | async def handler(request): 10 | return "ok" 11 | 12 | 13 | def test_run(): 14 | with pytest.raises(ValueError) as err: 15 | asgineer.run("foo", "nonexistingserver") 16 | assert "full path" in str(err).lower() 17 | 18 | with pytest.raises(ValueError) as err: 19 | asgineer.run("foo:bar", "nonexistingserver") 20 | assert "invalid server" in str(err).lower() 21 | 22 | with pytest.raises(ValueError) as err: 23 | asgineer.run(handler, "nonexistingserver") 24 | assert "invalid server" in str(err).lower() 25 | 26 | 27 | if __name__ == "__main__": 28 | test_run() 29 | -------------------------------------------------------------------------------- /examples/example_request_info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple Asgineer hanler to show information about the incoming request. 3 | """ 4 | 5 | import asgineer 6 | 7 | 8 | @asgineer.to_asgi 9 | async def main(request): 10 | lines = [ 11 | "", 12 | f"

    request.method

    {request.method}", 13 | f"

    request.url

    {request.url}", 14 | f"

    request.path

    {request.path}", 15 | f"

    request.querydict

    {request.querydict}", 16 | "

    request.headers

    ", 17 | "
    ".join(f"{key}: {val!r}" for key, val in request.headers.items()), 18 | "
    ", 19 | "", 20 | ] 21 | return "
    ".join(lines) 22 | 23 | 24 | if __name__ == "__main__": 25 | asgineer.run(main, "uvicorn", "0.0.0.0:8080") 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/start.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting started 3 | =============== 4 | 5 | 6 | Installation 7 | ============ 8 | 9 | To install or upgrade, run: 10 | 11 | .. code-block:: shell 12 | 13 | $ pip install -U asgineer 14 | 15 | 16 | Dependencies 17 | ============ 18 | 19 | Asgineer does not directly depend on any other libraries, but it 20 | does need an ASGI erver to run on. You need to install one 21 | of these seperately: 22 | 23 | * `Uvicorn `_ is bloody fast (thanks to uvloop and httptools). 24 | * `Hypercorn `_ can be multi-process (uses h11 end wsproto). 25 | * `Daphne `_ is part of the Django ecosystem (uses Twisted). 26 | * `Trio-web `_ is based on Trio, pre-alpa and incomplete, you can help improve it! 27 | * Others will surely come, also watch `this list `_ ... 28 | -------------------------------------------------------------------------------- /asgineer/_compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides compatibility for different async libs. Currently 3 | only supporting asyncio, but should not be too hard to add e.g. Trio, 4 | once Uvicorn has Trio support. 5 | """ 6 | 7 | import asyncio 8 | 9 | 10 | async def sleep(seconds): 11 | """An async sleep function. Uses asyncio. Can be extended to support Trio 12 | once we support that. 13 | """ 14 | 15 | if True: # if asyncio 16 | await asyncio.sleep(seconds) 17 | 18 | 19 | Event = asyncio.Event 20 | 21 | 22 | async def wait_for_any_then_cancel_the_rest(*coroutines): 23 | """Wait for any of the given coroutines to complete (or fail), and then 24 | cancels all the other co-routines. 25 | """ 26 | # Note: ensure_future == create_task. Less readable, but py36 compatible. 27 | if True: # if asyncio 28 | tasks = [asyncio.ensure_future(co) for co in coroutines] 29 | _done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) 30 | for task in pending: 31 | task.cancel() 32 | -------------------------------------------------------------------------------- /examples/example_assets.py: -------------------------------------------------------------------------------- 1 | """ 2 | This might well be the fastest way to host a static website, because: 3 | 4 | * Uvicorn (with uvloop and httptools) is lighning fast. 5 | * asgineer is just a minimal layer on top. 6 | * The ``make_asset_handler()`` takes care of HTTP caching and compression. 7 | 8 | """ 9 | 10 | import asgineer 11 | 12 | 13 | # Define a dictionary of assets. Change this to e.g. load them from the 14 | # file system, generate with Flexx, or whatever way you like. 15 | assets = { 16 | "index.html": "bar or bar", 17 | "foo.html": "This is foo, there is also bar", 18 | "bar.html": "This is bar, there is also foo", 19 | } 20 | 21 | 22 | # Create a handler to server the dicts of assets 23 | asset_handler = asgineer.utils.make_asset_handler(assets, max_age=100) 24 | 25 | 26 | @asgineer.to_asgi 27 | async def main(request): 28 | path = request.path.lstrip("/") or "index.html" 29 | return await asset_handler(request, path) 30 | 31 | 32 | if __name__ == "__main__": 33 | asgineer.run(main, "uvicorn", "localhost:8080") 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # ===== Project info 2 | 3 | [project] 4 | dynamic = ["version"] 5 | name = "asgineer" 6 | description = "A really thin ASGI web framework" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | authors = [{ name = "Almar Klein" }] 10 | keywords = ["ASGI", "web", "framework"] 11 | requires-python = ">= 3.8" 12 | dependencies = [] 13 | 14 | [project.optional-dependencies] 15 | lint = ["ruff"] 16 | docs = ["sphinx>7.2", "sphinx_rtd_theme"] 17 | tests = ["pytest", "pytest-cov", "requests", "websockets", "uvicorn", "hypercorn", "daphne"] 18 | dev = ["asgineer[lint,docs,tests]"] 19 | 20 | [project.urls] 21 | Homepage = "https://github.com/almarklein/asgineer" 22 | Documentation = "https://asgineer.readthedocs.io" 23 | Repository = "https://github.com/almarklein/asgineer" 24 | 25 | # ===== Building 26 | 27 | # To release: 28 | # - bump version, commit, tag, push. 29 | # - flit publish 30 | # - Publish tag on GH and write release notes 31 | 32 | [build-system] 33 | requires = ["flit_core >=3.2,<4"] 34 | build-backend = "flit_core.buildapi" 35 | 36 | # ===== Tooling 37 | 38 | [tool.ruff] 39 | line-length = 88 40 | 41 | [tool.ruff.lint] 42 | select = ["F", "E", "W", "B", "RUF"] 43 | ignore = [ 44 | "E501", # Line too long 45 | ] 46 | -------------------------------------------------------------------------------- /tests/test_meta.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test some meta stuff. 3 | """ 4 | 5 | import os 6 | import asgineer 7 | 8 | 9 | def test_namespace(): 10 | assert asgineer.__version__ 11 | 12 | ns = set(name for name in dir(asgineer) if not name.startswith("_")) 13 | 14 | ns.discard("testutils") # may or may not be imported 15 | 16 | assert ns == { 17 | "BaseRequest", 18 | "HttpRequest", 19 | "RequestSet", 20 | "WebsocketRequest", 21 | "DisconnectedError", 22 | "to_asgi", 23 | "run", 24 | "utils", 25 | "sleep", 26 | } 27 | assert ns == set(asgineer.__all__) 28 | 29 | 30 | def test_newlines(): 31 | # Let's be a bit pedantic about sanitizing whitespace :) 32 | 33 | for root, _dirs, files in os.walk(os.path.dirname(os.path.abspath(__file__))): 34 | for fname in files: 35 | if fname.endswith((".py", ".md", ".rst", ".yml")): 36 | with open(os.path.join(root, fname), "rb") as f: 37 | text = f.read().decode() 38 | assert "\r" not in text, f"{fname} has CR!" 39 | assert "\t" not in text, f"{fname} has tabs!" 40 | 41 | 42 | if __name__ == "__main__": 43 | test_namespace() 44 | test_newlines() 45 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test some specifics of the Request classes. 3 | """ 4 | 5 | import json 6 | 7 | from common import make_server 8 | 9 | 10 | async def handle_request_object1(request): 11 | assert request.scope["method"] == request.method 12 | d = dict( 13 | url=request.url, 14 | headers=request.headers, 15 | querylist=request.querylist, 16 | querydict=request.querydict, 17 | bodystring=(await request.get_body()).decode(), 18 | json=await request.get_json(), 19 | ) 20 | return d 21 | 22 | 23 | def test_request_object(): 24 | with make_server(handle_request_object1) as p: 25 | res = p.post("/xx/yy?arg=3&arg=4", b'{"foo": 42}') 26 | 27 | assert res.status == 200 28 | assert not p.out 29 | 30 | d = json.loads(res.body.decode()) 31 | assert d["url"] == p.url + "/xx/yy?arg=3&arg=4" 32 | assert "user-agent" in d["headers"] 33 | assert d["querylist"] == [["arg", "3"], ["arg", "4"]] # json makes tuples lists 34 | assert d["querydict"] == {"arg": "4"} 35 | assert json.loads(d["bodystring"]) == {"foo": 42} 36 | assert d["json"] == {"foo": 42} 37 | 38 | 39 | if __name__ == "__main__": 40 | from common import run_tests, set_backend_from_argv 41 | 42 | set_backend_from_argv() 43 | run_tests(globals()) 44 | -------------------------------------------------------------------------------- /docs/asgi.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | About ASGI 3 | ========== 4 | 5 | *You don't have to know or care about ASGI in order to use Asgineer, 6 | but here's a short summary.* 7 | 8 | 9 | What is ASGI? 10 | ============= 11 | 12 | The `ASGI `_ specification allows async 13 | web servers and frameworks to talk to each-other in a standardized way. 14 | You can select a framework (like Asgineer, Starlette, Responder, Quart, 15 | etc.) based on how you want to write your code, and you select a server 16 | (like Uvicorn, Hypercorn, Daphne) based on how fast/reliable/secure you 17 | want it to be. 18 | 19 | ASGI is like WSGI, but for async. 20 | 21 | In particular, the main part of an ASGI application looks something like this: 22 | 23 | .. code-block:: python 24 | 25 | async def application(scope, receive, send): 26 | ... 27 | 28 | 29 | Asgineer and other ASGI frameworks 30 | ================================== 31 | 32 | ASGI is great, but writing web apps directly in ASGI format is tedious. 33 | Asgineer is a tiny layer on top; it still feels a bit like ASGI, but nicer. 34 | 35 | Other ASGI frameworks include 36 | `Starlette `_, 37 | `Responder `_, 38 | `Quart `_, and 39 | `others `_. 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2018-2025, Almar Klein 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common utilities used in our test scripts. 3 | """ 4 | 5 | import os 6 | import sys 7 | 8 | from asgineer.testutils import ProcessTestServer, MockTestServer 9 | 10 | 11 | def get_backend(): 12 | return os.environ.get("ASGI_SERVER", "mock").lower() 13 | 14 | 15 | def set_backend_from_argv(): 16 | for arg in sys.argv: 17 | if arg.upper().startswith("--ASGI_SERVER="): 18 | os.environ["ASGI_SERVER"] = arg.split("=")[1].strip().lower() 19 | 20 | 21 | def run_tests(scope): 22 | for func in list(scope.values()): 23 | if callable(func) and func.__name__.startswith("test_"): 24 | print(f"Running {func.__name__} ...") 25 | func() 26 | print("Done") 27 | 28 | 29 | def filter_lines(lines): 30 | # Overloadable line filter 31 | skip = ( 32 | "Running on http", # older hypercorn 33 | "Running on 127.", # older hypercorn 34 | "Task was destroyed but", 35 | "task: `_ is a tool to write asynchronous 10 | web applications, using as few abstractions as possible, while still 11 | offering a friendly API. There is no fancy routing; you write an async 12 | request handler, and delegate to other handlers as needed. 13 | 14 | More precisely, Asgineer is a (thin) Python ASGI web microframework. 15 | 16 | 17 | Cool stuff: 18 | 19 | * When running on `Uvicorn `_, Asgineer is one 20 | of the fastest web frameworks available. 21 | * Asgineer has great support for chunked responses, long polling, server 22 | side events (SSE), and websockets. 23 | * Asgineer has utilities to help you serve your assets the right (and fast) way. 24 | * You can write your web app with Asgineer, and switch the underlying (ASGI) server 25 | without having to change your code. 26 | * Great test coverage. 27 | * Asgineer is so small that it fits in the palm of your hand! 28 | 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | :caption: Contents: 33 | 34 | start 35 | guide 36 | reference 37 | testutils 38 | asgi 39 | Examples ↪ 40 | -------------------------------------------------------------------------------- /examples/example_ws_echo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example websocket app that echos websocket messages. 3 | """ 4 | 5 | import asgineer 6 | 7 | 8 | index = """ 9 | 10 | 11 | 12 | 13 | Open console, and use "ws.send('x')" to send a message to the server. 14 | 15 | 31 | 32 | 33 | """.lstrip() 34 | 35 | 36 | @asgineer.to_asgi 37 | async def main(request): 38 | if not request.path.rstrip("/"): 39 | return index # Asgineer sets the text/html content type 40 | elif request.path.startswith("/ws"): 41 | assert request.scope["type"] == "websocket", "Expected ws" 42 | await request.accept() 43 | await websocket_handler(request) 44 | else: 45 | return 404, {}, f"404 not found {request.path}" 46 | 47 | 48 | async def websocket_handler(request): 49 | print("request", request) 50 | await request.send("hello!") 51 | while True: 52 | m = await request.receive() 53 | await request.send("echo: " + str(m)) 54 | print(m) 55 | 56 | print("done") 57 | 58 | 59 | if __name__ == "__main__": 60 | asgineer.run(main, "uvicorn", "localhost:80") 61 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /docs/testutils.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Asgineer test utilities 3 | ======================= 4 | 5 | When you've writting a fancy web application with Asgineer, you might want to 6 | write some unit tests. The ``asgineer.testutils`` module provides some utilities 7 | to help do that. It requires the ``requests`` library, and the ``websockets`` 8 | library when using websockets. 9 | 10 | 11 | Testing example 12 | =============== 13 | 14 | 15 | .. code-block:: python 16 | 17 | import json 18 | 19 | from asgineer.testutils import ProcessTestServer, MockTestServer 20 | 21 | 22 | # ----- Define handlers - you'd probably import these instead 23 | 24 | async def main_handler(request): 25 | if request.path.startswith('/api/'): 26 | return await api_handler(request) 27 | else: 28 | return "Hello world!" 29 | 30 | 31 | async def api_handler(request): 32 | return {'welcome': "This is a really silly API"} 33 | 34 | 35 | # ----- Test functions 36 | 37 | def test_my_app(): 38 | 39 | with MockTestServer(api_handler) as p: 40 | r = p.get('/api/') 41 | 42 | assert r.status == 200 43 | assert "welcome" in json.loads(r.body.decode()) 44 | 45 | 46 | with MockTestServer(main_handler) as p: 47 | r = p.get('') 48 | 49 | assert r.status == 200 50 | assert "Hello" in r.body.decode() 51 | 52 | 53 | if __name__ == '__main__': 54 | # Important: don't call the test functions from the root, 55 | # since this module gets re-imported! 56 | 57 | test_my_app() 58 | 59 | 60 | Instead of the ``MockTestServer`` you can also use the 61 | ``ProcessTestServer`` to test with a real server like ``uvicorn`` 62 | running in a subprocess. The API is exactly the same though! 63 | 64 | 65 | Test server classes 66 | =================== 67 | 68 | .. autoclass:: asgineer.testutils.BaseTestServer 69 | :members: 70 | 71 | .. autoclass:: asgineer.testutils.ProcessTestServer 72 | :members: 73 | 74 | .. autoclass:: asgineer.testutils.MockTestServer 75 | :members: 76 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test some specifics of the App class. 3 | """ 4 | 5 | import logging 6 | import asyncio 7 | 8 | import asgineer 9 | 10 | 11 | class LogCapturer(logging.Handler): 12 | def __init__(self): 13 | super().__init__() 14 | self.messages = [] 15 | 16 | def emit(self, record): 17 | self.messages.append(record.msg) 18 | 19 | def __enter__(self): 20 | logger = logging.getLogger("asgineer") 21 | logger.addHandler(self) 22 | return self 23 | 24 | def __exit__(self, *args, **kwargs): 25 | logger = logging.getLogger("asgineer") 26 | logger.removeHandler(self) 27 | 28 | 29 | async def handler(request): 30 | return "" 31 | 32 | 33 | def test_invalid_scope_types(): 34 | # All scope valid scope types are tested in other tests. Only test invalid here. 35 | 36 | app = asgineer.to_asgi(handler) 37 | 38 | scope = {"type": "notaknownscope"} 39 | loop = asyncio.new_event_loop() 40 | with LogCapturer() as cap: 41 | loop.run_until_complete(app(scope, None, None)) 42 | 43 | assert len(cap.messages) == 1 44 | assert "unknown" in cap.messages[0].lower() and "notaknownscope" in cap.messages[0] 45 | 46 | 47 | def test_lifespan(): 48 | app = asgineer.to_asgi(handler) 49 | 50 | scope = {"type": "lifespan"} 51 | loop = asyncio.new_event_loop() 52 | 53 | lifespan_messages = [ 54 | {"type": "lifespan.startup"}, 55 | {"type": "lifespan.bullshit"}, 56 | {"type": "lifespan.shutdown"}, 57 | ] 58 | sent = [] 59 | 60 | async def receive(): 61 | return lifespan_messages.pop(0) 62 | 63 | async def send(m): 64 | sent.append(m["type"]) 65 | 66 | with LogCapturer() as cap: 67 | loop.run_until_complete(app(scope, receive, send)) 68 | 69 | assert sent == ["lifespan.startup.complete", "lifespan.shutdown.complete"] 70 | 71 | assert len(cap.messages) == 3 72 | assert cap.messages[0].lower().count("starting up") 73 | assert "bullshit" in cap.messages[1] and "unknown" in cap.messages[1].lower() 74 | assert cap.messages[2].lower().count("shutting down") 75 | 76 | 77 | if __name__ == "__main__": 78 | test_invalid_scope_types() 79 | test_lifespan() 80 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Asgineer documentation build configuration file, created by 5 | # sphinx-quickstart on Wed Sep 26 15:13:51 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | 10 | import os 11 | import sys 12 | 13 | sys.path.insert(0, os.path.abspath(".")) 14 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 15 | 16 | import asgineer 17 | 18 | 19 | # -- General configuration ------------------------------------------------ 20 | 21 | extensions = [ 22 | "sphinx.ext.autodoc", 23 | "sphinx.ext.viewcode", 24 | "sphinx_rtd_theme", 25 | ] 26 | 27 | # Add any paths that contain templates here, relative to this directory. 28 | templates_path = ["_templates"] 29 | source_suffix = ".rst" 30 | master_doc = "index" 31 | 32 | # General information about the project. 33 | project = "Asgineer" 34 | copyright = "2018-2025, Almar Klein" 35 | author = "Almar Klein" 36 | release = asgineer.__version__ 37 | 38 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 39 | pygments_style = "default" 40 | todo_include_todos = False 41 | 42 | # -- Options for HTML output ---------------------------------------------- 43 | 44 | # The theme to use for HTML and HTML Help pages. See the documentation for 45 | # a list of builtin themes. 46 | # 47 | html_theme = "sphinx_rtd_theme" 48 | 49 | # Theme options are theme-specific and customize the look and feel of a theme 50 | # further. For a list of options available for each theme, see the 51 | # documentation. 52 | # 53 | # html_theme_options = {} 54 | 55 | # Add any paths that contain custom static files (such as style sheets) here, 56 | # relative to this directory. They are copied after the builtin static files, 57 | # so a file named "default.css" will overwrite the builtin "default.css". 58 | html_static_path = [] # ['_static'] 59 | 60 | # Custom sidebar templates, must be a dictionary that maps document names 61 | # to template names. 62 | # 63 | # This is required for the alabaster theme 64 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 65 | html_sidebars = { 66 | "**": [ 67 | "relations.html", # needs 'show_related': True theme option to display 68 | "searchbox.html", 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Asgineer 2 | A really thin ASGI web framework 🐍🤘 3 | 4 | [![Build Status](https://github.com/almarklein/asgineer/workflows/CI/badge.svg)](https://github.com/almarklein/asgineer/actions) 5 | [![Documentation Status](https://readthedocs.org/projects/asgineer/badge/?version=latest)](https://asgineer.readthedocs.io/?badge=latest) 6 | [![Package Version](https://badge.fury.io/py/asgineer.svg)](https://pypi.org/project/asgineer) 7 | 8 | 9 | ## Introduction 10 | 11 | [Asgineer](https://asgineer.readthedocs.io) is a tool to write asynchronous 12 | web applications, using as few abstractions as possible, while still 13 | offering a friendly API. The 14 | [guide](https://asgineer.readthedocs.io/guide.html) and 15 | [reference](https://asgineer.readthedocs.io/reference.html) take just a few 16 | minutes to read! 17 | 18 | When running Asgineer on [Uvicorn](https://github.com/encode/uvicorn), 19 | it is one of the fastest web frameworks available. It supports http 20 | long polling, server side events (SSE), and websockets. And has utilities 21 | to serve assets the right (and fast) way. 22 | 23 | Asgineer is proudly used to serve e.g. https://pyzo.org and https://timetagger.app. 24 | 25 | 26 | ## Example 27 | 28 | ```py 29 | # example.py 30 | 31 | import asgineer 32 | 33 | @asgineer.to_asgi 34 | async def main(request): 35 | return f"You requested {request.path}" 36 | 37 | if __name__ == '__main__': 38 | asgineer.run(main, 'uvicorn', 'localhost:8080') 39 | ``` 40 | 41 | You can start the server by running this script, or start it the *ASGI way*, e.g. 42 | with Uvicorn: 43 | ``` 44 | $ uvicorn example:main --host=localhost --port=8080 45 | ``` 46 | 47 | ## Installation and dependencies 48 | 49 | Asgineer needs Python 3.8 or higher (may work on older versions but is not tested). To install or upgrade, run: 50 | ``` 51 | $ pip install -U asgineer 52 | ``` 53 | 54 | Asgineer has zero hard dependencies, but it 55 | needs an ASGI server to run on, like 56 | [Uvicorn](https://github.com/encode/uvicorn), 57 | [Hypercorn](https://gitlab.com/pgjones/hypercorn), or 58 | [Daphne](https://github.com/django/daphne). 59 | 60 | 61 | ## Development 62 | 63 | Install with `pip install -e .[dev]`. 64 | 65 | * `ruff format` to apply code formatting. 66 | * `ruff check` to test for unused imports and more. 67 | * `pytest tests` to run the tests, optionally set the `ASGI_SERVER` environment variable. 68 | 69 | 70 | ## License 71 | 72 | BSD 2-clause. 73 | -------------------------------------------------------------------------------- /tests/test_unixsocket.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import tempfile 3 | import socket 4 | import time 5 | import shutil 6 | from pathlib import Path 7 | 8 | HTTP_REQUEST = ( 9 | "GET / HTTP/1.1\r\n" + "Host: example.com\r\n" + "Connection: close\r\n\r\n" 10 | ) 11 | 12 | 13 | def test_unixsocket(): 14 | for backend_module in ["hypercorn", "uvicorn", "daphne"]: 15 | # mkdtemp instead of normal tempdir to prevent any issues that 16 | # might occur with early clean up 17 | temp_folder = tempfile.mkdtemp() 18 | socket_location = f"{temp_folder}/socket" 19 | main_location = f"{temp_folder}/main.py" 20 | project_location = Path(__file__).parent.parent.absolute() 21 | code_to_run = "\n".join( 22 | [ 23 | "# this allows us not to install asgineer and still import it", 24 | "from importlib.util import spec_from_file_location", 25 | "import sys", 26 | f"spec = spec_from_file_location('asgineer', '{project_location}/asgineer/__init__.py')", 27 | "module = importlib.util.module_from_spec(spec)", 28 | "sys.modules[spec.name] = module ", 29 | "spec.loader.exec_module(module)", 30 | "", 31 | "import asgineer", 32 | "@asgineer.to_asgi", 33 | "async def main(request):", 34 | ' return "Ok"', 35 | "", 36 | "if __name__ == '__main__':", 37 | f" asgineer.run(main, '{backend_module}', 'unix:{socket_location}')", 38 | ] 39 | ) 40 | 41 | with open(main_location, "w") as file: 42 | file.write(code_to_run) 43 | 44 | process = subprocess.Popen(["python", main_location], cwd=project_location) 45 | 46 | with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: 47 | max_tries = 3 48 | for i in range(max_tries): 49 | time.sleep(1) 50 | try: 51 | client.connect(socket_location) 52 | client.send(HTTP_REQUEST.encode()) 53 | 54 | response = client.recv(1024).decode() 55 | 56 | if "200" in response: 57 | break 58 | else: 59 | raise RuntimeError("Unexpected response") 60 | except Exception as e: 61 | print(repr(e)) 62 | print(f"Failed {i} times (max: {max_tries}), retrying...") 63 | 64 | process.kill() 65 | shutil.rmtree(temp_folder) 66 | -------------------------------------------------------------------------------- /examples/example_ws_chat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example chat application using websockets. 3 | 4 | The handler is waiting for a message most of the time. When it receives 5 | one, it sends it to all other websockets. When the websocket 6 | disconnects, request.receive() raises DisconnectedError and the handler 7 | exits. The request is automatically removed from waiting_requests 8 | (that's the point of RequestSet). 9 | """ 10 | 11 | import asgineer 12 | 13 | 14 | waiting_requests = asgineer.RequestSet() 15 | 16 | 17 | @asgineer.to_asgi 18 | async def main(request): 19 | if request.path == "/": 20 | return HTML_TEMPLATE 21 | elif request.path == "/ws": 22 | await request.accept() 23 | await ws_handler(request) 24 | else: 25 | return 404, {}, "not found" 26 | 27 | 28 | async def ws_handler(request): 29 | waiting_requests.add(request) 30 | while True: 31 | msg = await request.receive() # raises DisconnectedError on disconnect 32 | for r in waiting_requests: 33 | await r.send(msg) 34 | 35 | 36 | HTML_TEMPLATE = """ 37 | 38 | 39 | 40 | 41 | Asgineer WS chat example 42 | 43 | 44 | 45 | 46 | 65 | 66 | 79 | 80 |
    81 |
    82 |
    83 | 84 | 85 |
    86 |
    87 | 88 | 89 | 90 | """.lstrip() 91 | 92 | 93 | if __name__ == "__main__": 94 | asgineer.run(main, "uvicorn", "localhost:80") 95 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Asgineer reference 3 | ================== 4 | 5 | This page contains the API documentation of Asgineer's functions and classes, 6 | as well as a description of Asgineer more implicit API. 7 | 8 | 9 | How to return a response 10 | ======================== 11 | 12 | An HTTP response consists of three things: a status code, headers, and the body. 13 | Your handler must return these as a tuple. You can also return just the 14 | body, or the body and headers; these are all equivalent: 15 | 16 | .. code-block:: python 17 | 18 | return 200, {}, 'hello' 19 | return {}, 'hello' 20 | return 'hello' 21 | 22 | If needed, the :func:`.normalize_response` function can be used to 23 | turn a response (e.g. of a subhandler) into a 3-element tuple. 24 | 25 | Asgineer automatically converts the body returned by your handler, and 26 | sets the appropriate headers: 27 | 28 | * A ``bytes`` object is passed unchanged. 29 | * A ``str`` object that starts with ```` or ```` is UTF-8 encoded, 30 | and the ``content-type`` header defaults to ``text/html``. 31 | * Any other ``str`` object is UTF-8 encoded, 32 | and the ``content-type`` header defaults to ``text/plain``. 33 | * A ``dict`` object is JSON-encoded, 34 | and the ``content-type`` header is set to ``application/json``. 35 | * An async generator can be provided as an alternative way to send a chunked response. 36 | 37 | See :func:`request.accept ` and :func:`request.send ` 38 | for a lower level API (for which the auto-conversion does not apply). 39 | 40 | 41 | Requests 42 | ======== 43 | 44 | .. autoclass:: asgineer.BaseRequest 45 | :members: 46 | 47 | .. autoclass:: asgineer.HttpRequest 48 | :members: 49 | 50 | .. autoclass:: asgineer.WebsocketRequest 51 | :members: 52 | 53 | .. autoclass:: asgineer.RequestSet 54 | :members: 55 | 56 | .. autoclass:: asgineer.DisconnectedError 57 | :members: 58 | 59 | 60 | Entrypoint functions 61 | ==================== 62 | 63 | .. autofunction:: asgineer.to_asgi 64 | 65 | .. autofunction:: asgineer.run 66 | 67 | 68 | 69 | Utility functions 70 | ================= 71 | 72 | The ``asgineer.utils`` module provides a few utilities for common tasks. 73 | 74 | .. autofunction:: asgineer.utils.sleep 75 | 76 | .. autofunction:: asgineer.utils.make_asset_handler 77 | 78 | .. autofunction:: asgineer.utils.normalize_response 79 | 80 | .. autofunction:: asgineer.utils.guess_content_type_from_body 81 | 82 | 83 | Details on Asgineer's behavior 84 | ============================== 85 | 86 | Asgineer will invoke your main handler for each incoming request. If an 87 | exception is raised inside the handler, this exception will be logged 88 | (including ``exc_info``) using the logger that can be obtained with 89 | ``logging.getLogger("asgineer")``, which by default writes to stderr. 90 | If possible, a status 500 (internal server error) response is sent back 91 | that includes the error message (without traceback). 92 | 93 | Similarly, when the returned response is flawed, a (slightly different) 94 | error message is logged and included in the response. 95 | 96 | In fact, Asgineer handles all exceptions, since the ASGI servers log 97 | errors in different ways (some just ignore them). If an error does fall 98 | through, it can be considered a bug. 99 | -------------------------------------------------------------------------------- /asgineer/_run.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module implements a ``run()`` function to start an ASGI server of choice. 3 | """ 4 | 5 | 6 | def run(app, server, bind="localhost:8080", **kwargs): 7 | """Run the given ASGI app with the given ASGI server. (This works for 8 | any ASGI app, not just Asgineer apps.) This provides a generic programatic 9 | API as an alternative to the standard ASGI-way to start a server. 10 | 11 | Arguments: 12 | 13 | * ``app`` (required): The ASGI application object, or a string ``"module.path:appname"``. 14 | * ``server`` (required): The name of the server to use, e.g. uvicorn/hypercorn/etc. 15 | * ``kwargs``: additional arguments to pass to the underlying server. 16 | """ 17 | 18 | # Compose application name 19 | if isinstance(app, str): 20 | appname = app 21 | if ":" not in appname: 22 | raise ValueError("If specifying an app by name, give its full path!") 23 | else: 24 | appname = app.__module__ + ":" + app.__name__ 25 | 26 | # Check server and bind 27 | assert isinstance(server, str), "asgineer.run() server arg must be a string." 28 | assert isinstance(bind, str), "asgineer.run() bind arg must be a string." 29 | assert ":" in bind, ( 30 | "asgineer.run() bind arg must be 'host:port'" + "or unix:/path/to/unixsocket" 31 | ) 32 | bind = bind.replace("localhost", "127.0.0.1") 33 | 34 | # Select server function 35 | try: 36 | func = SERVERS[server.lower()] 37 | except KeyError: 38 | raise ValueError(f"Invalid server specified: {server!r}") from None 39 | 40 | # Delegate 41 | return func(appname, bind, **kwargs) 42 | 43 | 44 | def _run_hypercorn(appname, bind, **kwargs): 45 | from hypercorn.__main__ import main 46 | 47 | # Hypercorn docs say: "Hypercorn has two loggers, an access logger and an error logger. 48 | # By default neither will actively log." So we dont need to do anything. 49 | 50 | kwargs["bind"] = bind 51 | 52 | args = [f"--{key.replace('_', '-')}={val!s}" for key, val in kwargs.items()] 53 | return main([*args, appname]) 54 | 55 | 56 | def _run_uvicorn(appname, bind, **kwargs): 57 | from uvicorn.main import main 58 | 59 | if bind.startswith("unix:/"): 60 | kwargs["uds"] = bind[5:] 61 | elif ":" in bind: 62 | host, _, port = bind.partition(":") 63 | kwargs["host"] = host 64 | kwargs["port"] = port 65 | else: 66 | kwargs["host"] = bind 67 | 68 | # Default to an error log_level, otherwise uvicorn is quite verbose 69 | kwargs.setdefault("log_level", "warning") 70 | 71 | args = [f"--{key.replace('_', '-')}={val!s}" for key, val in kwargs.items()] 72 | return main([*args, appname]) 73 | 74 | 75 | def _run_daphne(appname, bind, **kwargs): 76 | from daphne.cli import CommandLineInterface 77 | 78 | if bind.startswith("unix:/"): 79 | kwargs["u"] = bind[5:] 80 | elif ":" in bind: 81 | host, _, port = bind.partition(":") 82 | kwargs["bind"] = host 83 | kwargs["port"] = port 84 | else: 85 | kwargs["bind"] = bind 86 | 87 | # Default to warning level verbosity 88 | # levelmap = {"error": 0, "warn": 0, "warning": 0, "info": 1, "debug": 2} 89 | kwargs.setdefault("verbosity", 0) 90 | 91 | args = [f"--{key.replace('_', '-')}={val!s}" for key, val in kwargs.items()] 92 | return CommandLineInterface().run([*args, appname]) 93 | 94 | 95 | SERVERS = {"hypercorn": _run_hypercorn, "uvicorn": _run_uvicorn, "daphne": _run_daphne} 96 | -------------------------------------------------------------------------------- /tests/test_testutils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test testutils code. Note that most other tests implicitly test it. 3 | """ 4 | 5 | from common import make_server 6 | from asgineer.testutils import MockTestServer 7 | import asgineer 8 | 9 | 10 | async def handler1(request): 11 | return "hellow1" 12 | 13 | 14 | async def handler2(request): 15 | return await handler1(request) 16 | 17 | 18 | app1 = asgineer.to_asgi(handler1) 19 | 20 | 21 | @asgineer.to_asgi 22 | async def app2(request): 23 | return "hellow2" 24 | 25 | 26 | def test_http(): 27 | async def handler3(request): 28 | return "hellow3" 29 | 30 | async def handler4(request): 31 | return await handler1(request) 32 | 33 | _app3 = asgineer.to_asgi(handler3) 34 | 35 | with make_server(handler1) as p: 36 | assert p.get("").body == b"hellow1" 37 | 38 | with make_server(handler2) as p: 39 | assert p.get("").body == b"hellow1" 40 | 41 | with make_server(handler3) as p: 42 | assert p.get("").body == b"hellow3" 43 | 44 | # This would work with the Mock server, but not with uvicorn 45 | # with make_server(handler4) as p: 46 | # assert p.get("").body == b"hellow1" 47 | 48 | # This would work with the Mock server, but not with uvicorn 49 | # with make_server(app1) as p: 50 | # assert p.get("").body == b"hellow1" 51 | 52 | with make_server(app2) as p: 53 | assert p.get("").body == b"hellow2" 54 | 55 | # This would work with the Mock server, but not with uvicorn 56 | # with make_server(app3) as p: 57 | # assert p.get("").body == b"hellow3" 58 | 59 | 60 | def test_http_mock(): 61 | # We repeat the test, so that on non-mock server runs we can see 62 | # a better coverage of the testutils module 63 | 64 | async def handler3(request): 65 | return "hellow3" 66 | 67 | async def handler4(request): 68 | return await handler1(request) 69 | 70 | app3 = asgineer.to_asgi(handler3) 71 | 72 | with MockTestServer(handler1) as p: 73 | assert p.get("").body == b"hellow1" 74 | 75 | with MockTestServer(handler2) as p: 76 | assert p.get("").body == b"hellow1" 77 | 78 | with MockTestServer(handler3) as p: 79 | assert p.get("").body == b"hellow3" 80 | 81 | # Only with mock server! 82 | with MockTestServer(handler4) as p: 83 | assert p.get("").body == b"hellow1" 84 | 85 | # Only with mock server! 86 | with MockTestServer(app1) as p: 87 | assert p.get("").body == b"hellow1" 88 | 89 | with MockTestServer(app2) as p: 90 | assert p.get("").body == b"hellow2" 91 | 92 | # Only with mock server! 93 | with MockTestServer(app3) as p: 94 | assert p.get("").body == b"hellow3" 95 | 96 | 97 | def test_lifetime_messages(): 98 | async def handler(request): 99 | print("xxx") 100 | return "hellow" 101 | 102 | with MockTestServer(handler) as p: 103 | assert p.get("").body.decode() == "hellow" 104 | 105 | assert len(p.out.strip().splitlines()) == 3 106 | assert "Server is starting up" in p.out 107 | assert "xxx" in p.out 108 | assert "Server is shutting down" in p.out 109 | 110 | with make_server(handler) as p: 111 | assert p.get("").body.decode() == "hellow" 112 | 113 | # todo: somehow the lifetime messages dont show up (on uvicorn) and I dont know why. 114 | 115 | # assert len(p.out.strip().splitlines()) == 3 116 | # assert "Server is starting up" in p.out 117 | assert "xxx" in p.out 118 | # assert "Server is shutting down" in p.out 119 | 120 | 121 | if __name__ == "__main__": 122 | from common import run_tests, set_backend_from_argv 123 | 124 | set_backend_from_argv() 125 | run_tests(globals()) 126 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | 15 | lint-build: 16 | name: Lint 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: 3.13 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install ruff 30 | - name: Ruff lint 31 | run: | 32 | ruff check --output-format=github . 33 | - name: Ruff format 34 | run: | 35 | ruff format --check . 36 | 37 | docs-build: 38 | name: Test Docs 39 | runs-on: ubuntu-latest 40 | strategy: 41 | fail-fast: false 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Set up Python 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: '3.12' 48 | - name: Install dev dependencies 49 | run: | 50 | python -m pip install --upgrade pip 51 | pip install -U -e .[docs] 52 | - name: Build docs 53 | run: | 54 | cd docs 55 | make html SPHINXOPTS="-W --keep-going" 56 | 57 | test-builds: 58 | name: ${{ matrix.name }} 59 | runs-on: ${{ matrix.os }} 60 | strategy: 61 | fail-fast: false 62 | matrix: 63 | include: 64 | - name: Test Linux py38 65 | os: ubuntu-latest 66 | pyversion: '3.8' 67 | ASGI_SERVER: 'mock' 68 | - name: Test Linux py39 69 | os: ubuntu-latest 70 | pyversion: '3.9' 71 | ASGI_SERVER: 'mock' 72 | - name: Test Linux py310 73 | os: ubuntu-latest 74 | pyversion: '3.10' 75 | ASGI_SERVER: 'mock' 76 | - name: Test Linux py311 77 | os: ubuntu-latest 78 | pyversion: '3.11' 79 | ASGI_SERVER: 'mock' 80 | - name: Test Linux py312 81 | os: ubuntu-latest 82 | pyversion: '3.12' 83 | ASGI_SERVER: 'mock' 84 | - name: Test Linux py313 85 | os: ubuntu-latest 86 | pyversion: '3.13' 87 | ASGI_SERVER: 'mock' 88 | # 89 | - name: Test Linux uvicorn 90 | os: ubuntu-latest 91 | pyversion: '3.12' 92 | ASGI_SERVER: 'uvicorn' 93 | - name: Test Linux daphne 94 | os: ubuntu-latest 95 | pyversion: '3.12' 96 | ASGI_SERVER: 'daphne' 97 | #- name: Test Linux hypercorn - does not work with our test mechanics 98 | # os: ubuntu-latest 99 | # pyversion: '3.9' 100 | # ASGI_SERVER: 'hypercorn' 101 | 102 | steps: 103 | - uses: actions/checkout@v4 104 | - name: Set up Python ${{ matrix.pyversion }} 105 | uses: actions/setup-python@v5 106 | with: 107 | python-version: ${{ matrix.pyversion }} 108 | - name: Install dev dependencies 109 | run: | 110 | pip install -U pytest pytest-cov requests websockets uvicorn hypercorn daphne 111 | pip install . 112 | - name: Install ASGI framework (${{ matrix.ASGI_SERVER }}) 113 | if: ${{ matrix.ASGI_SERVER != 'mock' }} 114 | run: | 115 | pip install -U ${{ matrix.ASGI_SERVER }} 116 | - name: Unit tests 117 | env: 118 | ASGI_SERVER: ${{ matrix.ASGI_SERVER }} 119 | run: | 120 | cd tests 121 | pytest -v --cov=asgineer --cov-report=term-missing . 122 | -------------------------------------------------------------------------------- /examples/example_http.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example web app written in Asgineer. We have one main handler, which may 3 | delegate the request to one of the other handlers, which demonstrate a 4 | few different ways to send an http response. 5 | """ 6 | 7 | import asgineer 8 | 9 | index = """ 10 | 11 | 12 | 13 | 14 | Bytes
    15 | Text
    16 | JSON api
    17 | redirect
    18 | error1
    19 | error2
    20 | chunks
    21 | 22 | 23 | """.lstrip() 24 | 25 | 26 | @asgineer.to_asgi 27 | async def main(request): 28 | if not request.path.rstrip("/"): 29 | return index # Asgineer sets the text/html content type 30 | elif request.path.endswith(".bin"): 31 | return await bytes_handler(request) 32 | elif request.path.endswith(".txt"): 33 | return await text_handler(request) 34 | elif request.path.startswith("/api/"): 35 | return await json_api(request) 36 | elif request.path == "/redirect": 37 | return await redirect(request) 38 | elif request.path == "/error1": 39 | return await error1(request) 40 | elif request.path == "/error2": 41 | return await error2(request) 42 | elif request.path == "/chunks": 43 | return await chunks(request) 44 | else: 45 | return 404, {}, f"404 not found {request.path}" 46 | 47 | 48 | async def bytes_handler(request): 49 | """Returning bytes; a response in its purest form.""" 50 | return b"x" * 42 51 | 52 | 53 | async def text_handler(request): 54 | """Returning a string causes the content-type to default to text/plain. 55 | Note that the main handler also returns a string, but gets a text/html 56 | content-type because it starts with "" or "". 57 | """ 58 | return "Hello world" 59 | 60 | 61 | async def json_api(request): 62 | """Returning a dict will cause the content-type to default to 63 | application/json. 64 | """ 65 | return { 66 | "this": "is", 67 | "the": "api", 68 | "method": request.method, 69 | "apipath": request.path[4:], 70 | } 71 | 72 | 73 | async def redirect(request): 74 | """Handler to do redirects using HTTP status code 307. 75 | The url to redirect to must be given with a query parameter: 76 | http://localhost/redirect?url=http://example.com 77 | """ 78 | url = request.querydict.get("url", "") 79 | if url: 80 | return 307, {"location": url}, "Redirecting" 81 | else: 82 | return 500, {}, "specify the URL using a query param" 83 | 84 | 85 | async def error1(request): 86 | """Handler with a deliberate error.""" 87 | 88 | def foo(): 89 | 1 / 0 # noqa 90 | 91 | foo() 92 | 93 | 94 | async def error2(request): 95 | """Handler with a deliberate wrong result.""" 96 | return 400, "ho" 97 | 98 | 99 | async def chunks(request): 100 | """A handler that sends chunks at a slow pace. 101 | The browser will download the page over the range of 2 seconds, 102 | but only displays it when done. This e.g. allows streaming large 103 | files without using large amounts of memory. 104 | """ 105 | 106 | async def iter(): 107 | yield "" 108 | yield "Here are some chunks dripping in:
    " 109 | for _ in range(20): 110 | await asgineer.sleep(0.1) 111 | yield "CHUNK
    " 112 | yield "" 113 | 114 | return 200, {"content-type": "text/html"}, iter() 115 | 116 | 117 | if __name__ == "__main__": 118 | # asgineer.run(main, "hypercorn", "localhost:8080", workers=3) 119 | asgineer.run(main, "uvicorn", "localhost:8080") 120 | -------------------------------------------------------------------------------- /docs/guide.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Asgineer guide 3 | ============== 4 | 5 | 6 | A first look 7 | ============ 8 | 9 | Here's an example web application written with Asgineer: 10 | 11 | .. code-block:: python 12 | 13 | # example.py 14 | import asgineer 15 | 16 | @asgineer.to_asgi 17 | async def main(request): 18 | return f"You requested {request.path}" 19 | 20 | 21 | Responses are return values 22 | =========================== 23 | 24 | An HTTP response consists of three things: an integer 25 | `status code `_, 26 | a dictionary of `headers `_, 27 | and the response `body `_. 28 | 29 | In the above example, Asgineer sets the appropriate status code and 30 | headers so that the browser will render the result as a web page. In the 31 | :doc:`reference docs ` the rules are explained. 32 | 33 | You can also provide both headers and body, or all three values: 34 | 35 | .. code-block:: python 36 | 37 | async def main(request): 38 | return 200, {}, f"You requested {request.path}" 39 | 40 | 41 | Running the application 42 | ======================= 43 | 44 | Asgineer provides a ``run()`` function that is aware of the most common 45 | ASGI servers. Just put this at the bottom of the same file to enable 46 | running the file as a script: 47 | 48 | .. code-block:: python 49 | 50 | if __name__ == '__main__': 51 | asgineer.run('uvicorn', main, 'localhost:8080') 52 | # or use 'hypercorn', 'daphne', ... 53 | 54 | Alternatively, the above example can be run from the command line, using 55 | any ASGI server: 56 | 57 | .. code-block:: shell 58 | 59 | # Uvicorn: 60 | $ uvicorn example.py:main --host=localhost --port=8080 61 | # Hypercorn: 62 | $ hypercorn example.py:main --bind=localhost:8080 63 | # Daphne: 64 | $ daphne example:main --bind=localhost --port=8080 65 | 66 | 67 | Routing 68 | ======= 69 | 70 | Asgineer takes a "linear" approach to handling request. It avoids magic 71 | like routing systems, so you can easily follow how requests move through 72 | your code. To do the routing, make your main handler delegate to 73 | sub-handlers: 74 | 75 | .. code-block:: python 76 | 77 | import asgineer 78 | 79 | ASSETS = { 80 | 'main.js': ( 81 | b"console.log('Hello from asgineer!')", 82 | 'application/javascript' 83 | ) 84 | } 85 | 86 | 87 | @asgineer.to_asgi 88 | async def main(request): 89 | path = request.path 90 | if path == '/': 91 | return ( 92 | "" 93 | ' ' 94 | " Index page" 95 | "" 96 | ) 97 | elif path.startswith('/assets/'): 98 | return await asset_handler(request) 99 | elif path.startswith('/api/'): 100 | return await api_handler(request) 101 | else: 102 | return 404, {}, 'Page not found' 103 | 104 | 105 | async def asset_handler(request): 106 | fname = request.path.split('/assets/')[-1] 107 | if fname in ASSETS: 108 | body, content_type = ASSETS[fname] 109 | return {'content-type': content_type}, body 110 | else: 111 | return 404, {}, 'asset not found' 112 | 113 | 114 | async def api_handler(request): 115 | path = request.path.split('/api/')[-1] 116 | return {'path': path} 117 | 118 | 119 | For the common task of serving assets, Asgineer provides an easy way to do this 120 | correct and fast, with :func:`.make_asset_handler`. 121 | 122 | 123 | A lower level way to send responses 124 | =================================== 125 | 126 | The initial example can also be written using lower level mechanics. Note that 127 | Asgineer does not automatically set headers in this case: 128 | 129 | .. code-block:: python 130 | 131 | async def main(request): 132 | await request.accept(200, {"content-type": "text/html"}) 133 | await request.send("You requested {request.path}") 134 | 135 | This approach is intended for connections with a longer lifetime, such as 136 | chuncked responses, long polling, and server-side events (SSE). 137 | E.g. a chuncked response: 138 | 139 | .. code-block:: python 140 | 141 | async def main(request): 142 | await request.accept(200, {"content-type": "text/plain"}) 143 | async for chunk in some_generator(): 144 | await request.send(chunk) 145 | 146 | 147 | Websockets 148 | ========== 149 | 150 | Websocket handlers are written in a similar way: 151 | 152 | .. code-block:: python 153 | 154 | async def websocket_handler(request): 155 | await request.accept() 156 | 157 | # Wait for one message, which can be str or bytes 158 | m = await request.receive() 159 | 160 | # Send a message, which can be str, bytes or dict 161 | await request.send('Hello!') 162 | 163 | # Iterate over incoming messages until the connection closes 164 | async for msg in request.receive_iter(): 165 | await msg.send('echo ' + str(msg)) 166 | 167 | # Note: the connection is automatically closed when the handler returns 168 | 169 | 170 | ---- 171 | 172 | Read the :doc:`reference docs ` to read more about the details. 173 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import asgineer.utils 4 | 5 | from common import make_server, get_backend 6 | 7 | from pytest import raises, skip 8 | 9 | 10 | compressable_data = b"x" * 1000 11 | uncompressable_data = bytes([int(random.uniform(0, 255)) for i in range(1000)]) 12 | 13 | # def test_normalize_response() -> tested as part of test_app 14 | 15 | 16 | def test_make_asset_handler_fails(): 17 | if get_backend() != "mock": 18 | skip("Can only test this with mock server") 19 | 20 | with raises(TypeError): 21 | asgineer.utils.make_asset_handler("not a dict") 22 | with raises(ValueError): 23 | asgineer.utils.make_asset_handler({"notstrorbytes": 4}) 24 | 25 | 26 | def test_make_asset_handler(): 27 | if get_backend() != "mock": 28 | skip("Can only test this with mock server") 29 | 30 | # Make a server 31 | assets = { 32 | "foo.html": "bla", 33 | "foo.bmp": compressable_data, 34 | "foo.png": uncompressable_data, 35 | } 36 | assets.update({"b.xx": b"x", "t.xx": "x", "h.xx": "x"}) 37 | assets.update({"big.html": "x" * 10000, "bightml": "" + "x" * 100000}) 38 | handler = asgineer.utils.make_asset_handler(assets) 39 | server = make_server(asgineer.to_asgi(handler)) 40 | 41 | # Do some wrong requestrs 42 | r0a = server.get("foo") 43 | r0b = server.put("foo.html") 44 | 45 | assert r0a.status == 404 46 | assert r0b.status == 405 47 | 48 | # Do simple requests and check validity 49 | r1a = server.get("foo.html") 50 | r1b = server.request("head", "foo.html") 51 | r2a = server.get("foo.bmp") 52 | r2b = server.get("foo.png") 53 | 54 | assert r1a.status == 200 and r1b.status == 200 55 | assert len(r1a.body) == 3 56 | assert len(r1b.body) == 0 # HEAD's have no body 57 | 58 | for r in (r1a, r1b): 59 | assert r.headers["content-type"] == "text/html" 60 | assert int(r.headers["content-length"]) == 3 61 | 62 | assert r2a.status == 200 and r2b.status == 200 63 | assert r2b.headers["content-type"] == "image/png" 64 | assert len(r2a.body) == 1000 and len(r2b.body) == 1000 65 | 66 | assert server.get("h.xx").headers["content-type"] == "text/html" 67 | assert server.get("t.xx").headers["content-type"] == "text/plain" 68 | assert server.get("b.xx").headers["content-type"] == "application/octet-stream" 69 | 70 | assert r1a.headers["etag"] 71 | assert "max-age=0" in [x.strip() for x in r1a.headers["cache-control"].split(",")] 72 | assert r2a.headers["etag"] 73 | assert r2b.headers["etag"] 74 | assert r2a.headers["etag"] != r2b.headers["etag"] 75 | 76 | assert r1a.headers.get("content-encoding", "identity") == "identity" 77 | assert r2a.headers.get("content-encoding", "identity") == "identity" 78 | assert r2b.headers.get("content-encoding", "identity") == "identity" 79 | 80 | # Now do request with gzip on 81 | r3 = server.get("foo.html", headers={"accept-encoding": "gzip"}) 82 | r4a = server.get("foo.bmp", headers={"accept-encoding": "gzip"}) 83 | r4b = server.get("foo.png", headers={"accept-encoding": "gzip"}) 84 | 85 | assert r3.status == 200 and r4a.status == 200 and r4b.status == 200 86 | assert len(r3.body) == 3 and len(r4a.body) < 50 and len(r4b.body) == 1000 87 | 88 | assert r3.headers.get("content-encoding", "identity") == "identity" # small 89 | assert r4a.headers.get("content-encoding", "identity") == "gzip" # big enough 90 | assert r4b.headers.get("content-encoding", "identity") == "identity" # entropy 91 | 92 | # Now do a request with etag 93 | r5 = server.get( 94 | "foo.html", 95 | headers={"accept-encoding": "gzip", "if-none-match": r1a.headers["etag"]}, 96 | ) 97 | r6 = server.get( 98 | "foo.png", 99 | headers={"accept-encoding": "gzip", "if-none-match": r2b.headers["etag"]}, 100 | ) 101 | 102 | assert r5.status == 304 and r6.status == 304 103 | assert len(r5.body) == 0 and len(r6.body) == 0 104 | 105 | assert r5.headers.get("content-encoding", "identity") == "identity" 106 | assert r6.headers.get("content-encoding", "identity") == "identity" 107 | 108 | # Dito, but with wrong etag 109 | r7 = server.get( 110 | "foo.html", 111 | headers={"accept-encoding": "gzip", "if-none-match": r2b.headers["etag"]}, 112 | ) 113 | r8 = server.get( 114 | "foo.png", headers={"accept-encoding": "gzip", "if-none-match": "xxxx"} 115 | ) 116 | 117 | assert r7.status == 200 and r8.status == 200 118 | assert len(r7.body) == 3 and len(r8.body) == 1000 119 | 120 | # Big html files will be zipped, but must retain content type 121 | for fname in ("big.html", "bightml"): 122 | r = server.get(fname) 123 | assert r.status == 200 124 | assert r.headers.get("content-encoding", "identity") == "identity" 125 | assert r.headers["content-type"] == "text/html" 126 | plainbody = r.body 127 | 128 | r = server.get(fname, headers={"accept-encoding": "gzip"}) 129 | assert r.status == 200 130 | assert r.headers.get("content-encoding", "identity") == "gzip" 131 | assert r.headers["content-type"] == "text/html" 132 | assert len(r.body) < len(plainbody) 133 | 134 | 135 | def test_make_asset_handler_max_age(): 136 | if get_backend() != "mock": 137 | skip("Can only test this with mock server") 138 | 139 | # Make a server 140 | assets = { 141 | "foo.html": "bla", 142 | "foo.bmp": compressable_data, 143 | "foo.png": uncompressable_data, 144 | } 145 | handler = asgineer.utils.make_asset_handler(assets, max_age=9999) 146 | server = make_server(asgineer.to_asgi(handler)) 147 | 148 | # Do simple requests and check validity 149 | r1 = server.get("foo.html") 150 | assert r1.status == 200 151 | 152 | assert r1.headers["etag"] 153 | assert "max-age=9999" in [x.strip() for x in r1.headers["cache-control"].split(",")] 154 | 155 | 156 | if __name__ == "__main__": 157 | test_make_asset_handler_fails() 158 | test_make_asset_handler() 159 | test_make_asset_handler_max_age() 160 | -------------------------------------------------------------------------------- /examples/example_chat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example chat application using short polling, long polling and SSE. 3 | 4 | In this setup, the long-polling and SSE requests are put to sleep, 5 | and woken up when new data is available. 6 | 7 | This approach works well for long polling, but not for websockets. 8 | See example_ws_chat.py for a similar chat based on websockets. Note that 9 | SSE could follow a more hybrid approach. 10 | """ 11 | 12 | import asgineer 13 | 14 | 15 | @asgineer.to_asgi 16 | async def main(request): 17 | if request.path == "/": 18 | return HTML_TEMPLATE.replace("POLL_METHOD", "none") 19 | elif request.path == "/short_poll": 20 | return HTML_TEMPLATE.replace("POLL_METHOD", "short_poll") 21 | elif request.path == "/long_poll": 22 | return HTML_TEMPLATE.replace("POLL_METHOD", "long_poll") 23 | elif request.path == "/sse": 24 | return HTML_TEMPLATE.replace("POLL_METHOD", "sse") 25 | elif request.path == "/say": 26 | message_bytes = await request.get_body(1024) 27 | await post_new_message(message_bytes.decode()) 28 | return 200, {}, b"" 29 | elif request.path.startswith("/messages/"): 30 | return await messages_handler(request) 31 | else: 32 | return 404, {}, "not found" 33 | 34 | 35 | messages = [] 36 | waiting_requests = asgineer.RequestSet() 37 | 38 | 39 | async def post_new_message(message): 40 | messages.append(message) 41 | messages[:-32] = [] 42 | for r in waiting_requests: 43 | await r.wakeup() 44 | 45 | 46 | async def messages_handler(request): 47 | poll_method = request.path.split("/", 2)[-1] 48 | 49 | if poll_method == "short_poll": 50 | # Short poll: simply respond with the messages. 51 | await request.accept(200, {"content-type": "text/plain"}) 52 | await request.send("
    ".join(messages)) 53 | 54 | elif poll_method == "long_poll": 55 | # Long poll: wait with sending messages until we have new data. 56 | waiting_requests.add(request) 57 | await request.accept(200, {"content-type": "text/plain"}) 58 | await request.sleep_while_connected(3) 59 | await request.send("
    ".join(messages)) 60 | 61 | elif poll_method == "sse": 62 | # Server Side Events: send messages each time we have new data. 63 | # Also need special headers. 64 | waiting_requests.add(request) 65 | sse_headers = { 66 | "content-type": "text/event-stream", 67 | "cache-control": "no-cache", 68 | "connection": "keep-alive", 69 | } 70 | await request.accept(200, sse_headers) 71 | while True: 72 | await request.sleep_while_connected(10) 73 | await request.send(f"event: message\ndata: {'
    '.join(messages)}\n\n") 74 | 75 | else: 76 | raise ValueError(f"Invalid message handler endpoint: {request.path}") 77 | 78 | 79 | HTML_TEMPLATE = """ 80 | 81 | 82 | 83 | 84 | Asgineer example polling: POLL_METHOD 85 | 86 | 87 | 88 | 89 | 139 | 140 | 153 | 154 |
    155 | Current polling method: POLL_METHOD

    156 | No polling (only on page load)
    157 | Short Polling
    158 | Long Polling
    159 | Server Side Events (SSE)
    160 |
    161 | 162 |
    163 |
    164 |
    165 | 166 | 167 |
    168 |
    169 | 170 | 171 | 172 | """.lstrip() 173 | 174 | 175 | if __name__ == "__main__": 176 | asgineer.run(main, "uvicorn", "localhost:80") 177 | -------------------------------------------------------------------------------- /asgineer/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some utilities for common tasks. 3 | """ 4 | 5 | import gzip 6 | import hashlib 7 | import mimetypes 8 | 9 | from ._app import normalize_response, guess_content_type_from_body 10 | from ._compat import sleep 11 | 12 | 13 | __all__ = [ 14 | "guess_content_type_from_body", 15 | "make_asset_handler", 16 | "normalize_response", 17 | "sleep", 18 | ] 19 | 20 | VIDEO_EXTENSIONS = ".mp4", ".3gp", ".webm" 21 | 22 | 23 | def make_asset_handler(assets, max_age=0, min_compress_size=256): 24 | """Get a coroutine function for efficiently serving in-memory assets. 25 | 26 | The resulting handler functon takes care of setting the appropriate 27 | content-type header, sending compressed responses when 28 | possible/sensible, and applying appropriate HTTP caching (using 29 | etag and cache-control headers). Usage: 30 | 31 | .. code-block:: python 32 | 33 | assets = ... # a dict mapping filenames to asset bodies (str/bytes) 34 | 35 | asset_handler = make_asset_handler(assets) 36 | 37 | async def some_handler(request): 38 | path = request.path.lstrip("/") 39 | return await asset_handler(request, path) 40 | 41 | 42 | Parameters for ``make_asset_handler()``: 43 | 44 | * ``assets (dict)``: The assets to serve. The keys represent "file names" 45 | and must be str. The values must be bytes or str. 46 | * ``max_age (int)``: The maximum age of the assets. This is used as a hint 47 | for the client (e.g. the browser) for how long an asset is "fresh" 48 | and can be used before validating it. The default is zero. Can be 49 | set higher for assets that hardly ever change (e.g. images and fonts). 50 | * ``min_compress_size (int)``: The minimum size of the body for compressing 51 | an asset. Default 256. 52 | 53 | Parameters for the handler: 54 | 55 | * ``request (Request)``: The Asgineer request object (for the request headers). 56 | * ``path (str)``: A key in the asset dictionary. Case insensitive. 57 | If not given or None, ``request.path.lstrip("/")`` is used. 58 | 59 | Handler behavior: 60 | 61 | * If the given path is not present in the asset dict (case insensitive), 62 | a 404-not-found response is returned. 63 | * The ``etag`` header is set to a (sha256) hash of the body of the asset. 64 | * The ``cache-control`` header is set to "public, must-revalidate, max-age=xx". 65 | * If the request has a ``if-none-match`` header that matches the etag, 66 | the handler responds with 304 (indicating to the client that the resource 67 | is still up-to-date). 68 | * Otherwise, the asset body is returned, setting the ``content-type`` header 69 | based on the filename extensions of the keys in the asset dicts. If the 70 | key does not contain a dot, the ``content-type`` will be based on the 71 | body of the asset. 72 | * If the asset is over ``min_compress_size`` bytes, is not a video, the 73 | request has a ``accept-encoding`` header that contains "gzip", 74 | and the compressed data is less that 90% of the raw data, the 75 | data is send in compressed form. 76 | """ 77 | 78 | if not isinstance(assets, dict): 79 | raise TypeError("make_asset_handler() expects a dict of assets") 80 | if not (isinstance(max_age, int) and max_age >= 0): # pragma: no cover 81 | raise TypeError("make_asset_handler() max_age must be a positive int") 82 | 83 | # Store etags, prepare unzipped/zipped bodies, store ctypes 84 | etags = {} 85 | unzipped = {} 86 | zipped = {} 87 | ctypes = {} 88 | for path, body in assets.items(): 89 | # Get lowercase path 90 | lpath = path.lower() 91 | # Get binary body 92 | if isinstance(body, bytes): 93 | bbody = body 94 | elif isinstance(body, str): 95 | bbody = body.encode() 96 | else: 97 | raise ValueError("Asset bodies must be bytes or str.") 98 | # Store etag 99 | etags[lpath] = hashlib.sha256(bbody).hexdigest() 100 | # Store unzipped body 101 | unzipped[lpath] = bbody 102 | # Store zipped body if it makes sense 103 | if len(bbody) >= min_compress_size: 104 | if not lpath.endswith(VIDEO_EXTENSIONS): 105 | bbody_zipped = gzip.compress(bbody) 106 | if len(bbody_zipped) < 0.90 * len(bbody): 107 | zipped[lpath] = bbody_zipped 108 | # Store ctype 109 | ctype, _ = mimetypes.guess_type(lpath) 110 | if ctype: 111 | ctypes[lpath] = ctype 112 | else: 113 | ctypes[lpath] = guess_content_type_from_body(body) 114 | 115 | async def asset_handler(request, path=None): 116 | if request.method not in ("GET", "HEAD"): 117 | return 405, {}, "Method not allowed" 118 | 119 | if path is None: 120 | path = request.path.lstrip("/") 121 | path = path.lower() 122 | 123 | if path not in unzipped: 124 | return 404, {}, "File not found" 125 | 126 | assert path in etags 127 | assert path in ctypes 128 | 129 | status = 200 130 | body = unzipped[path] 131 | headers = {} 132 | headers["cache-control"] = f"public, must-revalidate, max-age={max_age:d}" 133 | headers["content-length"] = str(len(body)) 134 | headers["content-type"] = ctypes[path] 135 | headers["etag"] = f'"{etags[path]}"' 136 | 137 | # Get body, zip if we should and can 138 | if "gzip" in request.headers.get("accept-encoding", "") and path in zipped: 139 | body = zipped[path] 140 | headers["content-encoding"] = "gzip" 141 | headers["content-length"] = str(len(body)) 142 | 143 | # If client already has the exact asset, send confirmation now 144 | if request.headers.get("if-none-match") == headers["etag"]: 145 | status = 304 146 | body = b"" 147 | # https://www.rfc-editor.org/rfc/rfc7232#section-4.1 148 | headers.pop("content-encoding", None) 149 | headers.pop("content-length", None) 150 | headers.pop("content-type", None) 151 | 152 | # The response to a head request should not include a body 153 | if request.method == "HEAD": 154 | body = b"" 155 | 156 | # Note that we always return bytes, not a stream-response. The 157 | # assets used with this utility are assumed to be small-ish, 158 | # since they are in-memory. 159 | return status, headers, body 160 | 161 | return asset_handler 162 | -------------------------------------------------------------------------------- /tests/test_http_long.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test behavior for HTTP request like streams, long-polling, and SSE. 3 | """ 4 | 5 | import gc 6 | import time 7 | import asyncio 8 | 9 | import asgineer 10 | 11 | from common import make_server, get_backend 12 | from pytest import raises, skip 13 | 14 | 15 | def test_stream1(): 16 | # Stream version using an async gen (old-style) 17 | async def stream_handler(request): 18 | async def stream(): 19 | for i in range(10): 20 | await asgineer.sleep(0.1) 21 | yield str(i) 22 | 23 | return 200, {}, stream() 24 | 25 | with make_server(stream_handler) as p: 26 | res = p.get("/") 27 | 28 | assert res.body == b"0123456789" 29 | assert not p.out 30 | 31 | 32 | def test_stream2(): 33 | # New-style stream version 34 | async def stream_handler(request): 35 | await request.accept(200, {}) 36 | for i in range(10): 37 | await asgineer.sleep(0.1) 38 | await request.send(str(i)) 39 | 40 | with make_server(stream_handler) as p: 41 | res = p.get("/") 42 | 43 | assert res.body == b"0123456789" 44 | assert not p.out 45 | 46 | 47 | def test_stream3(): 48 | # This is basically long polling 49 | async def stream_handler(request): 50 | await request.accept(200, {}) 51 | await request.sleep_while_connected(1.0) 52 | for i in range(10): 53 | await request.send(str(i)) 54 | 55 | with make_server(stream_handler) as p: 56 | res = p.get("/") 57 | 58 | assert res.body == b"0123456789" 59 | assert not p.out 60 | 61 | if get_backend() == "mock": 62 | # The mock server will close the connection directly when we do not 63 | # use GET. So we can test that kind of behavior. 64 | with make_server(stream_handler) as p: 65 | res = p.put("/") 66 | assert res.body == b"" 67 | assert not p.out 68 | 69 | # With POST it will even behave a bit shitty, but in a way we could 70 | # expect a server to behave (receive() returning None). 71 | with make_server(stream_handler) as p: 72 | res = p.post("/") 73 | assert res.body == b"" 74 | assert not p.out 75 | 76 | 77 | def test_stream4(): 78 | # This is basically sse 79 | async def stream_handler(request): 80 | await request.accept(200, {}) 81 | for i in range(10): 82 | await request.sleep_while_connected(0.1) 83 | await request.send(str(i)) 84 | 85 | with make_server(stream_handler) as p: 86 | res = p.get("/") 87 | 88 | assert res.body == b"0123456789" 89 | assert not p.out 90 | 91 | 92 | def test_stream5(): 93 | # This is real sse (from the server side) 94 | async def stream_handler(request): 95 | sse_headers = { 96 | "content-type": "text/event-stream", 97 | "cache-control": "no-cache", 98 | "connection": "keep-alive", 99 | } 100 | await request.accept(200, sse_headers) 101 | for i in range(10): 102 | await request.sleep_while_connected(0.1) 103 | await request.send(f"event: message\ndata:{i!s}\n\n") 104 | 105 | with make_server(stream_handler) as p: 106 | res = p.get("/") 107 | 108 | val = "".join(x.split("data:")[-1] for x in res.body.decode().split("\n\n")) 109 | assert val == "0123456789" 110 | 111 | 112 | def test_stream_wakeup(): 113 | # This tests that the request object has a wakeup (async) method. 114 | # And that the signal is reset when sleep_while_connected() is entered. 115 | 116 | async def stream_handler(request): 117 | await request.accept(200, {}) 118 | await request.wakeup() # internal knowledge: signal does not yet exist 119 | await request.sleep_while_connected(0.01) # force signal to be created 120 | await request.wakeup() # signal exists 121 | await request.sleep_while_connected(1.0) 122 | for i in range(10): 123 | await request.send(str(i)) 124 | 125 | t0 = time.perf_counter() 126 | with make_server(stream_handler) as p: 127 | res = p.get("/") 128 | t1 = time.perf_counter() 129 | 130 | assert res.body == b"0123456789" 131 | assert not p.out 132 | assert 1 < (t1 - t0) < 3 # probably like 1.1 133 | 134 | 135 | def test_evil_handler(): 136 | # If, due to a "typo", the DisconnectedError is swallowed, trying 137 | # to use the connection will raise IOError (which means invalid use) 138 | 139 | if get_backend() != "mock": 140 | skip("Can only test this with mock server") 141 | 142 | async def stream_handler(request): 143 | await request.accept(200, {}) 144 | while True: 145 | try: 146 | await request.sleep_while_connected(0.1) 147 | except asgineer.DisconnectedError: 148 | pass 149 | await request.send(b"x") 150 | 151 | with make_server(stream_handler) as p: 152 | res = p.put("/") 153 | 154 | assert res.body == b"x" 155 | assert "already disconnected" in p.out.lower() 156 | 157 | 158 | def test_request_set(): 159 | loop = asyncio.new_event_loop() 160 | 161 | s1 = asgineer.RequestSet() 162 | r1 = asgineer.BaseRequest(None) 163 | r2 = asgineer.BaseRequest(None) 164 | r3 = asgineer.BaseRequest(None) 165 | 166 | s1.add(r1) 167 | s1.add(r2) 168 | 169 | with raises(TypeError): 170 | s1.add("not a request object") 171 | 172 | # Put it in more sets 173 | s2 = asgineer.RequestSet() 174 | s3 = asgineer.RequestSet() 175 | for r in s1: 176 | s2.add(r) 177 | s3.add(r) 178 | 179 | # We only add r3 to s1, otherwise the gc test becomes flaky for some reason 180 | s1.add(r3) 181 | 182 | assert len(s1) == 3 183 | assert len(s2) == 2 184 | assert len(s3) == 2 185 | 186 | # Empty s3 187 | s3.discard(r1) 188 | assert len(s3) == 1 189 | s3.clear() 190 | assert len(s3) == 0 191 | 192 | # Asgineer app does this at the end 193 | loop.run_until_complete(r1._destroy()) 194 | loop.run_until_complete(r2._destroy()) 195 | assert len(s1) == 1 196 | assert len(s2) == 0 197 | 198 | # But the set items are weak refs too 199 | del r3 200 | gc.collect() 201 | 202 | assert len(s1) == 0 203 | assert len(s2) == 0 204 | assert len(s3) == 0 205 | 206 | 207 | if __name__ == "__main__": 208 | test_stream_wakeup() 209 | test_evil_handler() 210 | 211 | test_request_set() 212 | 213 | test_stream1() 214 | test_stream2() 215 | test_stream3() 216 | test_stream4() 217 | test_stream5() 218 | -------------------------------------------------------------------------------- /asgineer/_app.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module implements an ASGI application class that forms the adapter 3 | between the user-defined handler function and the ASGI server. 4 | """ 5 | 6 | import sys 7 | import json 8 | import logging 9 | import inspect 10 | from . import _request 11 | from ._request import HttpRequest, WebsocketRequest, DisconnectedError 12 | 13 | # Initialize the logger 14 | logger = logging.getLogger("asgineer") 15 | logger.propagate = False 16 | logger.setLevel(logging.INFO) 17 | _handler = logging.StreamHandler(sys.stderr) 18 | _handler.setFormatter( 19 | logging.Formatter( 20 | fmt="[%(levelname)s %(asctime)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S" 21 | ) 22 | ) 23 | logger.addHandler(_handler) 24 | 25 | 26 | def normalize_response(response): 27 | """Normalize the given response, by always returning a 3-element tuple 28 | (status, headers, body). The body is not "resolved"; it is safe 29 | to call this function multiple times on the same response. 30 | """ 31 | # Get status, headers and body from the response 32 | if isinstance(response, tuple): 33 | if len(response) == 3: 34 | status, headers, body = response 35 | elif len(response) == 2: 36 | status = 200 37 | headers, body = response 38 | elif len(response) == 1: 39 | status, headers, body = 200, {}, response[0] 40 | else: 41 | raise ValueError(f"Handler returned {len(response)}-tuple.") 42 | else: 43 | status, headers, body = 200, {}, response 44 | 45 | # Validate status and headers 46 | if not isinstance(status, int): 47 | raise ValueError(f"Status code must be an int, not {type(status)}") 48 | if not isinstance(headers, dict): 49 | raise ValueError(f"Headers must be a dict, not {type(headers)}") 50 | 51 | headers = {k.lower(): v for k, v in headers.items()} 52 | return status, headers, body 53 | 54 | 55 | def guess_content_type_from_body(body): 56 | """Guess the content-type based of the body. 57 | 58 | * "text/html" for str bodies starting with ```` or ````. 59 | * "text/plain" for other str bodies. 60 | * "application/json" for dict bodies. 61 | * "application/octet-stream" otherwise. 62 | """ 63 | if isinstance(body, str): 64 | if body.startswith(("", "", "")): 65 | return "text/html" 66 | else: 67 | return "text/plain" 68 | elif isinstance(body, dict): 69 | return "application/json" 70 | else: 71 | return "application/octet-stream" 72 | 73 | 74 | def to_asgi(handler): 75 | """Convert a request handler (a coroutine function) to an ASGI 76 | application, which can be served with an ASGI server, such as 77 | Uvicorn, Hypercorn, Daphne, etc. 78 | """ 79 | 80 | if not inspect.iscoroutinefunction(handler): 81 | raise TypeError( 82 | "asgineer.to_asgi() handler function must be a coroutine function." 83 | ) 84 | 85 | async def application_wrapper(scope, receive, send): 86 | return await asgineer_application(handler, scope, receive, send) 87 | 88 | application_wrapper.__module__ = handler.__module__ 89 | application_wrapper.__name__ = handler.__name__ 90 | application_wrapper.__doc__ = handler.__doc__ 91 | application_wrapper.asgineer_handler = handler 92 | return application_wrapper 93 | 94 | 95 | async def asgineer_application(handler, scope, receive, send): 96 | # server_version = scope["asgi"].get("version", "2.0") 97 | # spec_version = scope["asgi"].get("spec_version", "2.0") 98 | 99 | if scope["type"] == "http": 100 | request = HttpRequest(scope, receive, send) 101 | await _handle_http(handler, request) 102 | elif scope["type"] == "websocket": 103 | request = WebsocketRequest(scope, receive, send) 104 | await _handle_websocket(handler, request) 105 | elif scope["type"] == "lifespan": 106 | await _handle_lifespan(receive, send) 107 | else: 108 | logger.warning(f"Unknown ASGI type {scope['type']}") 109 | 110 | 111 | async def _handle_lifespan(receive, send): 112 | while True: 113 | message = await receive() 114 | if message["type"] == "lifespan.startup": 115 | try: 116 | # Could do startup stuff here 117 | logger.info("Server is starting up") 118 | except Exception as err: # pragma: no cover 119 | await send({"type": "lifespan.startup.failed", "message": str(err)}) 120 | else: 121 | await send({"type": "lifespan.startup.complete"}) 122 | elif message["type"] == "lifespan.shutdown": 123 | try: 124 | # Could do shutdown stuff here 125 | logger.info("Server is shutting down") 126 | except Exception as err: # pragma: no cover 127 | await send({"type": "lifespan.shutdown.failed", "message": str(err)}) 128 | else: 129 | await send({"type": "lifespan.shutdown.complete"}) 130 | return 131 | else: 132 | logger.warning(f"Unknown lifespan message {message['type']}") 133 | 134 | 135 | async def _handle_http(handler, request): 136 | try: 137 | # Call request handler to get the result 138 | where = "request handler" 139 | result = await handler(request) 140 | 141 | def _response_has_body( 142 | request, response_status, response_headers, response_body 143 | ): 144 | # https://www.rfc-editor.org/rfc/rfc7230#section-3.3 145 | if request.method == "HEAD": 146 | return False 147 | if (100 <= response_status <= 199) or response_status in [204, 304]: 148 | return False 149 | return True 150 | 151 | if request._app_state == _request.CONNECTING: 152 | # Process the handler output 153 | where = "processing handler output" 154 | status, headers, body = normalize_response(result) 155 | # Make sure that there is a content type 156 | if ( 157 | _response_has_body(request, status, headers, body) 158 | and "content-type" not in headers 159 | ): 160 | headers["content-type"] = guess_content_type_from_body(body) 161 | # Convert the body 162 | if isinstance(body, bytes): 163 | pass 164 | elif isinstance(body, str): 165 | body = body.encode() 166 | elif isinstance(body, dict): 167 | try: 168 | body = json.dumps(body).encode() 169 | except Exception as err: 170 | raise ValueError(f"Could not JSON encode body: {err}") from None 171 | elif inspect.isasyncgen(body): 172 | # Returning an async generator used to be THE way to do chunked 173 | # responses before version 0.8. We keep it for backwards 174 | # compatibility, and because it can be quite nice. 175 | pass 176 | else: 177 | if inspect.isgenerator(body): 178 | raise ValueError( 179 | "Body cannot be a regular generator, use an async generator." 180 | ) 181 | elif inspect.iscoroutine(body): 182 | raise ValueError("Body cannot be a coroutine, forgot await?") 183 | else: 184 | raise ValueError(f"Body cannot be {type(body)}.") 185 | # Send response. Note that per the spec, if we do not specify 186 | # the content-length, the server sets Transfer-Encoding to chunked. 187 | if isinstance(body, bytes): 188 | where = "sending response" 189 | if ( 190 | _response_has_body(request, status, headers, body) 191 | and "content-length" not in headers 192 | ): 193 | headers["content-length"] = str(len(body)) 194 | await request.accept(status, headers) 195 | await request.send(body, more=False) 196 | else: 197 | where = "sending chunked response" 198 | accepted = False 199 | async for chunk in body: 200 | if not isinstance(chunk, (bytes, str)): 201 | raise ValueError("Response chunks must be bytes or str.") 202 | if not accepted: 203 | await request.accept(status, headers) 204 | accepted = True 205 | await request.send(chunk) 206 | 207 | else: 208 | # If the handler accepted the request, it should use send, not return. 209 | if result is not None: 210 | raise IOError("Handlers that call request.accept() should return None.") 211 | 212 | # Mark end of data, if needed 213 | if request._app_state == _request.CONNECTED: 214 | where = "finalizing response" 215 | await request.send(b"", more=False) 216 | 217 | except DisconnectedError: 218 | pass # Not really an error 219 | 220 | except Exception as err: 221 | # Process errors. We log them, and if possible send a 500 222 | error_text = f"{type(err).__name__} in {where}: {err!s}" 223 | logger.error(error_text, exc_info=err) 224 | if request._app_state == _request.CONNECTING: 225 | await request.accept(500, {}) 226 | await request.send(error_text, more=False) 227 | elif request._app_state == _request.CONNECTED: 228 | await request.send(b"", more=False) # At least close it 229 | 230 | finally: 231 | # Clean up 232 | try: 233 | await request._destroy() 234 | except Exception as err: # pragma: no cover 235 | logger.error(f"Error in cleanup: {err!s}", exc_info=err) 236 | 237 | 238 | async def _handle_websocket(handler, request): 239 | try: 240 | result = await handler(request) 241 | 242 | if result is not None: 243 | error_text = ( 244 | "A websocket handler should return None; " 245 | + "use request.send() and request.receive() to communicate." 246 | ) 247 | raise IOError(error_text) 248 | 249 | except DisconnectedError: 250 | pass # Not really an error 251 | 252 | except Exception as err: 253 | error_text = f"{type(err).__name__} in websocket handler: {err!s}" 254 | logger.error(error_text, exc_info=err) 255 | 256 | finally: 257 | # The ASGI spec specifies that ASGI servers should close the 258 | # ws connection when the task ends. At the time of writing 259 | # (04-10-2018), only Uvicorn does this. And at 18-08-2020 Daphne 260 | # still doesn't. So ... just close for good measure. 261 | try: 262 | await request.close() 263 | except Exception: # pragma: no cover 264 | pass 265 | 266 | # Also clean up 267 | try: 268 | await request._destroy() 269 | except Exception as err: # pragma: no cover 270 | logger.error(f"Error in ws cleanup: {err!s}", exc_info=err) 271 | -------------------------------------------------------------------------------- /tests/test_websocket.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test behavior for websocket handlers. 3 | """ 4 | 5 | import sys 6 | 7 | import asgineer 8 | from common import make_server, get_backend 9 | from pytest import skip 10 | 11 | 12 | def test_websocket1(): 13 | # Send messages from server to client 14 | 15 | async def handle_ws(request): 16 | await request.accept() 17 | await request.send("some text") 18 | await request.send(b"some bytes") 19 | await request.send({"some": "json"}) 20 | await request.close() 21 | 22 | async def client(ws): 23 | messages = [] 24 | async for m in ws: 25 | messages.append(m) 26 | return messages 27 | 28 | with make_server(handle_ws) as p: 29 | messages = p.ws_communicate("/", client) 30 | 31 | assert messages == ["some text", b"some bytes", b'{"some": "json"}'] 32 | assert not p.out 33 | 34 | # Send messages from server to client, let the client stop 35 | 36 | async def handle_ws(request): 37 | await request.accept() 38 | await request.send("hi") 39 | await request.send("CLIENT_CLOSE") 40 | # Wait for client to close connection 41 | async for m in request.receive_iter(): 42 | print(m) 43 | 44 | async def client(ws): 45 | messages = [] 46 | async for m in ws: 47 | messages.append(m) 48 | if m == "CLIENT_CLOSE": 49 | break 50 | return messages 51 | 52 | with make_server(handle_ws) as p: 53 | messages = p.ws_communicate("/", client) 54 | 55 | assert messages == ["hi", "CLIENT_CLOSE"] 56 | assert not p.out 57 | 58 | 59 | def test_websocket2(): 60 | # Send messages from client to server 61 | 62 | async def handle_ws(request): 63 | await request.accept() 64 | async for m in request.receive_iter(): 65 | print(m) 66 | sys.stdout.flush() 67 | 68 | async def client(ws): 69 | messages = [] 70 | await ws.send("hi") 71 | await ws.send("there") 72 | await ws.close() 73 | async for m in ws: 74 | messages.append(m) 75 | if m == "CLIENT_CLOSE": 76 | break 77 | return messages 78 | 79 | with make_server(handle_ws) as p: 80 | messages = p.ws_communicate("/", client) 81 | 82 | assert messages == [] 83 | assert p.out == "hi\nthere" 84 | 85 | # Send messages from server to client, let the server stop 86 | 87 | async def handle_ws(request): 88 | await request.accept() 89 | async for m in request.receive_iter(): 90 | print(m) 91 | if m == "SERVER_STOP": 92 | break 93 | sys.stdout.flush() 94 | 95 | async def client(ws): 96 | messages = [] 97 | await ws.send("hi") 98 | await ws.send("there") 99 | await ws.send("SERVER_STOP") 100 | async for m in ws: 101 | messages.append(m) 102 | if m == "CLIENT_CLOSE": 103 | break 104 | return messages 105 | 106 | with make_server(handle_ws) as p: 107 | messages = p.ws_communicate("/", client) 108 | 109 | assert messages == [] 110 | assert p.out == "hi\nthere\nSERVER_STOP" 111 | 112 | 113 | def test_websocket_echo(): 114 | async def handle_ws(request): 115 | await request.accept() 116 | async for m in request.receive_iter(): 117 | if m == "SERVER_STOP": 118 | break 119 | else: 120 | await request.send(m) 121 | sys.stdout.flush() 122 | 123 | async def client(ws): 124 | messages = [] 125 | await ws.send("hi") 126 | await ws.send("there") 127 | await ws.send("SERVER_STOP") 128 | async for m in ws: 129 | messages.append(m) 130 | if m == "CLIENT_CLOSE": 131 | break 132 | return messages 133 | 134 | with make_server(handle_ws) as p: 135 | messages = p.ws_communicate("/", client) 136 | 137 | assert messages == ["hi", "there"] 138 | assert not p.out 139 | 140 | 141 | def test_websocket_receive(): 142 | async def handle_ws(request): 143 | await request.accept() 144 | print(await request.receive_json()) 145 | print(await request.receive_json()) 146 | sys.stdout.flush() 147 | 148 | async def client(ws): 149 | await ws.send('{"foo": 3}') 150 | await ws.send(b'{"bar": 3}') 151 | 152 | with make_server(handle_ws) as p: 153 | p.ws_communicate("/", client) 154 | 155 | assert p.out == "{'foo': 3}\n{'bar': 3}" 156 | 157 | 158 | def test_websocket_cannot_send_after_close1(): 159 | async def handle_ws(request): 160 | await request.accept() 161 | await request.send("foo") # fine 162 | async for m in request.receive_iter(): 163 | print(m) 164 | sys.stdout.flush() 165 | await request.send("bar") # not ok 166 | 167 | async def client(ws): 168 | messages = [] 169 | await ws.send("hi") 170 | await ws.send("there") 171 | await ws.close() 172 | async for m in ws: 173 | messages.append(m) 174 | return messages 175 | 176 | with make_server(handle_ws) as p: 177 | messages = p.ws_communicate("/", client) 178 | 179 | assert messages == ["foo"] 180 | assert "hi\nthere" in p.out.strip() 181 | assert "Cannot send to a disconnected ws" in p.out 182 | 183 | 184 | def test_websocket_cannot_send_after_close2(): 185 | async def handle_ws(request): 186 | await request.accept() 187 | await request.send("foo") # fine 188 | await request.close() 189 | await request.send("bar") # not ok 190 | 191 | async def client(ws): 192 | messages = [] 193 | async for m in ws: 194 | messages.append(m) 195 | return messages 196 | 197 | with make_server(handle_ws) as p: 198 | messages = p.ws_communicate("/", client) 199 | 200 | assert messages == ["foo"] 201 | assert "Cannot send to a closed ws" in p.out 202 | 203 | 204 | def test_websocket_receive_too_much(): 205 | async def handle_ws1(request): 206 | await request.accept() 207 | async for m in request.receive_iter(): 208 | print(m) 209 | sys.stdout.flush() 210 | 211 | async def handle_ws2(request): 212 | await request.accept() 213 | print(await request.receive()) 214 | print(await request.receive()) 215 | sys.stdout.flush() 216 | 217 | async def client(ws): 218 | await ws.send("hellow") 219 | # await ws.close() # this would be the nice behavior 220 | 221 | with make_server(handle_ws1) as p: 222 | p.ws_communicate("/", client) 223 | 224 | assert "hellow" == p.out.strip() 225 | 226 | with make_server(handle_ws2) as p: 227 | p.ws_communicate("/", client) 228 | 229 | assert "hellow" == p.out # DisconnectError is not reported 230 | 231 | 232 | def test_websocket_receive_after_close(): 233 | if get_backend() in ("daphne", "uvicorn"): 234 | skip("This test outcome is ill defined, skipping for daphne and uvicorn") 235 | 236 | async def handle_ws1(request): 237 | await request.accept() 238 | await request.close() 239 | print(await request.receive()) # This is fine, maybe 240 | 241 | async def client(ws): 242 | await ws.send("hellow") 243 | await ws.close() 244 | 245 | with make_server(handle_ws1) as p: 246 | p.ws_communicate("/", client) 247 | out = p.out.strip() 248 | 249 | # Acually, uvicorn gives empty string, daphne gives error, not sure 250 | # what the official behavior is, I guess we'll allow both. 251 | print("receive_after_close:", out) 252 | assert out in ("", "hellow") 253 | 254 | 255 | def test_websocket_receive_after_disconnect1(): 256 | async def handle_ws1(request): 257 | await request.accept() 258 | async for m in request.receive_iter(): # stops at DisconnectedError 259 | print(m) 260 | sys.stdout.flush() 261 | await request.receive() 262 | 263 | async def client(ws): 264 | await ws.send("hellow") 265 | await ws.close() 266 | 267 | with make_server(handle_ws1) as p: 268 | p.ws_communicate("/", client) 269 | 270 | assert p.out.strip().startswith("hellow") 271 | assert "Cannot receive from ws that already disconnected" in p.out 272 | 273 | 274 | def test_websocket_receive_after_disconnect2(): 275 | async def handle_ws1(request): 276 | await request.accept() 277 | try: 278 | print(await request.receive()) 279 | sys.stdout.flush() 280 | print(await request.receive()) 281 | except asgineer.DisconnectedError: 282 | pass 283 | await request.receive() 284 | 285 | async def client(ws): 286 | await ws.send("hellow") 287 | await ws.close() 288 | 289 | with make_server(handle_ws1) as p: 290 | p.ws_communicate("/", client) 291 | 292 | assert p.out.strip().startswith("hellow") 293 | assert "Cannot receive from ws that already disconnected" in p.out 294 | 295 | 296 | def test_websocket_send_invalid_data(): 297 | if get_backend() in ("daphne", "uvicorn"): 298 | skip("This test outcome is ill defined, skipping for daphne and uvicorn") 299 | 300 | async def handle_ws(request): 301 | await request.accept() 302 | await request.send(4) 303 | 304 | async def client(ws): 305 | await ws.send("hellow") 306 | 307 | with make_server(handle_ws) as p: 308 | p.ws_communicate("/", client) 309 | 310 | assert "TypeError" in p.out 311 | 312 | 313 | def test_websocket_no_accept1(): 314 | async def handle_ws(request): 315 | await request.send("some text") 316 | 317 | async def client(ws): 318 | messages = [] 319 | async for m in ws: 320 | messages.append(m) 321 | if m == "CLIENT_CLOSE": 322 | break 323 | return messages 324 | 325 | with make_server(handle_ws) as p: 326 | messages = p.ws_communicate("/", client) 327 | 328 | assert messages == [] or messages is None # handshake fails 329 | assert "Error in websocket handler" in p.out 330 | assert "Cannot send before" in p.out 331 | 332 | 333 | def test_websocket_no_accept2(): 334 | async def handle_ws(request): 335 | await request.receive() 336 | 337 | async def client(ws): 338 | return [] 339 | 340 | with make_server(handle_ws) as p: 341 | p.ws_communicate("/", client) 342 | 343 | assert "Error in websocket handler" in p.out 344 | assert "Cannot receive before" in p.out 345 | 346 | 347 | def test_websocket_double_accept(): 348 | async def handle_ws(request): 349 | await request.accept() 350 | await request.accept() 351 | await request.send("some text") 352 | 353 | async def client(ws): 354 | messages = [] 355 | async for m in ws: 356 | messages.append(m) 357 | if m == "CLIENT_CLOSE": 358 | break 359 | return messages 360 | 361 | with make_server(handle_ws) as p: 362 | messages = p.ws_communicate("/", client) 363 | 364 | assert messages == [] or messages is None # handshake fails 365 | assert "Error in websocket handler" in p.out 366 | assert "Cannot accept" in p.out 367 | 368 | 369 | def test_websocket_accept_while_disconnected1(): 370 | async def handle_ws(request): 371 | x = request._client_state 372 | await request.accept() 373 | request._client_state = x # Pretend it is in initial state 374 | await request.accept() 375 | await request.send("some text") 376 | 377 | async def client(ws): 378 | await ws.close() 379 | 380 | with make_server(handle_ws) as p: 381 | messages = p.ws_communicate("/", client) 382 | 383 | assert messages == [] or messages is None # handshake fails 384 | assert not p.out # DisconnectError is not reported 385 | 386 | 387 | def test_websocket_accept_while_disconnected2(): 388 | async def handle_ws(request): 389 | x = request._client_state 390 | await request.accept() 391 | request._client_state = x # Pretend it is in initial state 392 | try: 393 | await request.accept() 394 | except asgineer.DisconnectedError: 395 | print("foobar1") 396 | try: 397 | await request.accept() 398 | except asgineer.DisconnectedError: 399 | print("foobar1") 400 | await request.send("some text") 401 | 402 | async def client(ws): 403 | await ws.close() 404 | 405 | with make_server(handle_ws) as p: 406 | messages = p.ws_communicate("/", client) 407 | 408 | assert messages == [] or messages is None # handshake fails 409 | assert "foobar1" in p.out 410 | assert "foobar2" not in p.out 411 | assert "Cannot accept ws that already disconnected" in p.out 412 | 413 | 414 | def test_websocket_should_return_none(): 415 | # Returning a value, even if the rest of the request is ok will 416 | # make the server log an error. 417 | 418 | async def handle_ws(request): 419 | await request.accept() 420 | await request.send("some text") 421 | return 7 422 | 423 | async def client(ws): 424 | messages = [] 425 | async for m in ws: 426 | messages.append(m) 427 | if m == "CLIENT_CLOSE": 428 | break 429 | return messages 430 | 431 | with make_server(handle_ws) as p: 432 | messages = p.ws_communicate("/", client) 433 | 434 | assert messages == ["some text"] # the request went fine 435 | assert "should return None" in p.out 436 | 437 | # This is a classic case where a user is doing it wrong, and the error 438 | # message should (hopefully) help. 439 | 440 | async def handle_ws(request): 441 | return "hi" 442 | 443 | async def client(ws): 444 | messages = [] 445 | async for m in ws: 446 | messages.append(m) 447 | if m == "CLIENT_CLOSE": 448 | break 449 | return messages 450 | 451 | with make_server(handle_ws) as p: 452 | messages = p.ws_communicate("/", client) 453 | 454 | assert messages == [] or messages is None # handshake fails 455 | assert "should return None" in p.out 456 | 457 | 458 | if __name__ == "__main__": 459 | from common import run_tests, set_backend_from_argv 460 | 461 | set_backend_from_argv() 462 | run_tests(globals()) 463 | -------------------------------------------------------------------------------- /asgineer/_request.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module implements the HttpRequest and WebsocketRequest classes that 3 | are passed as an argument into the user's handler function. 4 | """ 5 | 6 | import weakref 7 | import json 8 | from urllib.parse import parse_qsl # urlparse, unquote 9 | 10 | from ._compat import sleep, Event, wait_for_any_then_cancel_the_rest 11 | 12 | 13 | CONNECTING = 0 14 | CONNECTED = 1 15 | DONE = 2 16 | DISCONNECTED = 3 17 | 18 | 19 | class DisconnectedError(IOError): 20 | """An error raised when the connection is disconnected by the client. 21 | Subclass of IOError. You don't need to catch these - it is considered 22 | ok for a handler to exit by this. 23 | """ 24 | 25 | 26 | class BaseRequest: 27 | """Base request class, defining the properties to get access to 28 | the request metadata. 29 | """ 30 | 31 | __slots__ = ("__weakref__", "_headers", "_querylist", "_request_sets", "_scope") 32 | 33 | def __init__(self, scope): 34 | self._scope = scope 35 | self._headers = None 36 | self._querylist = None 37 | self._request_sets = set() 38 | 39 | async def _destroy(self): 40 | """Method to be used internally to perform cleanup.""" 41 | for s in self._request_sets: 42 | try: 43 | s.discard(self) 44 | except Exception: # pragma: no cover 45 | pass 46 | self._request_sets.clear() 47 | 48 | @property 49 | def scope(self): 50 | """A dict representing the raw ASGI scope. See the 51 | `ASGI reference `_ 52 | for details. 53 | """ 54 | return self._scope 55 | 56 | @property 57 | def method(self): 58 | """The HTTP method (string). E.g. 'HEAD', 'GET', 'PUT', 'POST', 'DELETE'.""" 59 | return self._scope["method"] 60 | 61 | @property 62 | def headers(self): 63 | """A dictionary representing the headers. Both keys and values are 64 | lowercase strings. 65 | """ 66 | # We can assume the headers to be made lowercase by h11/httptools/etc. right? 67 | if self._headers is None: 68 | self._headers = dict( 69 | (key.decode(), val.decode()) for key, val in self._scope["headers"] 70 | ) 71 | return self._headers 72 | 73 | @property 74 | def url(self): 75 | """The full (unquoted) url, composed of scheme, host, port, 76 | path, and query parameters (string). 77 | """ 78 | url = f"{self.scheme}://{self.host}:{self.port}{self.path}" 79 | if self.querylist: 80 | url += "?" + "&".join(f"{key}={val}" for key, val in self.querylist) 81 | return url 82 | 83 | @property 84 | def scheme(self): 85 | """The URL scheme (string). E.g. 'http' or 'https'.""" 86 | return self._scope["scheme"] 87 | 88 | @property 89 | def host(self): 90 | """he requested host name, taken from the Host header, 91 | or ``scope['server'][0]`` if there is not Host header. 92 | See also ``scope['server']`` and ``scope['client']``. 93 | """ 94 | return self.headers.get("host", self._scope["server"][0]).split(":")[0] 95 | 96 | @property 97 | def port(self): 98 | """The server's port (integer).""" 99 | return self._scope["server"][1] 100 | 101 | @property 102 | def path(self): 103 | """The path part of the URL (a string, with percent escapes decoded).""" 104 | return ( 105 | self._scope.get("root_path", "") + self._scope["path"] 106 | ) # is percent-decoded 107 | 108 | @property 109 | def querylist(self): 110 | """A list with ``(key, value)`` tuples, representing the URL query parameters.""" 111 | if self._querylist is None: 112 | q = self._scope["query_string"] # bytes, not percent decoded 113 | self._querylist = parse_qsl(q.decode()) 114 | return self._querylist 115 | 116 | @property 117 | def querydict(self): 118 | """A dictionary representing the URL query parameters.""" 119 | return dict(self.querylist) 120 | 121 | 122 | class HttpRequest(BaseRequest): 123 | """Subclass of BaseRequest to represent an HTTP request. An object 124 | of this class is passed to the request handler. 125 | """ 126 | 127 | __slots__ = ( 128 | "_app_state", 129 | "_body", 130 | "_client_state", 131 | "_receive", 132 | "_send", 133 | "_wakeup_event", 134 | ) 135 | 136 | def __init__(self, scope, receive, send): 137 | super().__init__(scope) 138 | self._receive = receive 139 | self._send = send 140 | self._client_state = CONNECTED # CONNECTED -> DONE -> DISCONNECTED 141 | self._app_state = CONNECTING # CONNECTING -> CONNECTED -> DONE 142 | self._body = None 143 | self._wakeup_event = None 144 | 145 | async def accept(self, status=200, headers=None): 146 | """Accept this http request. Sends the status code and headers. 147 | 148 | In Asgineer, a response can be provided in two ways. The simpler 149 | (preferred) way is to let the handler return status, headers 150 | and body. Alternatively, one can use use ``accept()`` and 151 | ``send()``. In the latter case, the handler must return None. 152 | 153 | Using ``accept()`` and ``send()`` is mostly intended for 154 | long-lived responses such as chunked data, long polling and 155 | SSE. 156 | 157 | Note that when using a handler return value, Asgineer 158 | automatically sets headers based on the body. This is not the 159 | case when using ``accept``. (Except that the ASGI server will 160 | set "transfer-encoding" to "chunked" if "content-length" is not 161 | specified.) 162 | """ 163 | headers = {} if headers is None else headers 164 | # Check status 165 | if self._app_state != CONNECTING: 166 | raise IOError("Cannot accept an already accepted connection.") 167 | # Check and convert input 168 | status = int(status) 169 | try: 170 | rawheaders = [(k.encode(), v.encode()) for k, v in headers.items()] 171 | except Exception: 172 | raise TypeError("Header keys and values must all be strings.") from None 173 | # Send our first message 174 | self._app_state = CONNECTED 175 | msg = {"type": "http.response.start", "status": status, "headers": rawheaders} 176 | await self._send(msg) 177 | 178 | async def _receive_chunk(self): 179 | """Receive a chunk of data, returning a bytes object. 180 | Raises ``DisconnectedError`` when the connection is closed. 181 | """ 182 | # Check status 183 | if self._client_state == DISCONNECTED: 184 | raise IOError("Cannot receive from connection that already disconnected.") 185 | # Receive 186 | message = await self._receive() 187 | mt = "http.disconnect" if message is None else message["type"] 188 | if mt == "http.request": 189 | data = bytes(message.get("body", b"")) # some servers return bytearray 190 | if not message.get("more_body", False): 191 | self._client_state = DONE 192 | return data 193 | elif mt == "http.disconnect": 194 | self._client_state = DISCONNECTED 195 | raise DisconnectedError() 196 | else: # pragma: no cover 197 | raise IOError(f"Unexpected message type: {mt}") 198 | 199 | async def send(self, data, more=True): 200 | """Send (a chunk of) data, representing the response. Note that 201 | ``accept()`` must be called first. See ``accept()`` for details. 202 | """ 203 | # Compose message 204 | more = bool(more) 205 | if isinstance(data, str): 206 | data = data.encode() 207 | elif not isinstance(data, bytes): 208 | raise TypeError(f"Can only send bytes/str over http, not {type(data)}.") 209 | message = {"type": "http.response.body", "body": data, "more_body": more} 210 | # Send 211 | if self._app_state == CONNECTED: 212 | if not more: 213 | self._app_state = DONE 214 | await self._send(message) 215 | elif self._app_state == CONNECTING: 216 | raise IOError("Cannot send before calling accept.") 217 | else: 218 | raise IOError("Cannot send to a closed connection.") 219 | 220 | async def sleep_while_connected(self, seconds): 221 | """Async sleep, wake-able, and only while the connection is active. 222 | Intended for use in long polling and server side events (SSE): 223 | 224 | * Returns after the given amount of seconds. 225 | * Returns when the request ``wakeup()`` is called. 226 | * Raises ``DisconnectedError`` when the connection is closed. 227 | * Note that this drops all received data. 228 | """ 229 | if self._client_state == DISCONNECTED: 230 | raise IOError("Cannot wait for connection that already disconnected.") 231 | if self._wakeup_event is None: 232 | self._wakeup_event = Event() 233 | self._wakeup_event.clear() 234 | await wait_for_any_then_cancel_the_rest( 235 | sleep(seconds), 236 | self._wakeup_event.wait(), 237 | self._receive_until_disconnect(), 238 | ) 239 | if self._client_state == DISCONNECTED: 240 | raise DisconnectedError() # see _receive_until_disconnect 241 | 242 | async def _receive_until_disconnect(self): 243 | """Keep receiving until the client disconnects.""" 244 | while True: 245 | try: 246 | await self._receive_chunk() 247 | except DisconnectedError: 248 | break # will re-raise in sleep_while_connected 249 | 250 | async def wakeup(self): 251 | """Awake any tasks that are waiting in ``sleep_while_connected()``.""" 252 | if self._wakeup_event is not None: 253 | self._wakeup_event.set() 254 | 255 | async def iter_body(self): 256 | """Async generator that iterates over the chunks in the body. 257 | During iteration you should probably take measures to avoid excessive 258 | memory usage to prevent server vulnerabilities. 259 | Raises ``DisconnectedError`` when the connection is closed. 260 | """ 261 | # Check status 262 | if self._client_state == DONE: 263 | raise IOError("Cannot receive an http request that is already consumed.") 264 | # Iterate 265 | while True: 266 | chunk = await self._receive_chunk() 267 | yield chunk 268 | if self._client_state != CONNECTED: # i.e. DONE or DISCONNECTED 269 | break 270 | 271 | async def get_body(self, limit=10 * 2**20): 272 | """Async function to get the bytes of the body. 273 | If the end of the stream is not reached before the byte limit 274 | is reached (default 10MiB), raises an ``IOError``. 275 | """ 276 | if self._body is None: 277 | nbytes = 0 278 | chunks = [] 279 | async for chunk in self.iter_body(): 280 | nbytes += len(chunk) 281 | if nbytes > limit: 282 | chunks.clear() 283 | raise IOError("Request body too large.") 284 | chunks.append(chunk) 285 | self._body = b"".join(chunks) 286 | return self._body 287 | 288 | async def get_json(self, limit=10 * 2**20): 289 | """Async function to get the body as a dict. 290 | If the end of the stream is not reached before the byte limit 291 | is reached (default 10MiB), raises an ``IOError``. 292 | """ 293 | body = await self.get_body(limit) 294 | return json.loads(body.decode()) 295 | 296 | 297 | class WebsocketRequest(BaseRequest): 298 | """Subclass of BaseRequest to represent a websocket request. An 299 | object of this class is passed to the request handler. 300 | """ 301 | 302 | __slots__ = ("_app_state", "_client_state", "_receive", "_send") 303 | 304 | def __init__(self, scope, receive, send): 305 | assert scope["type"] == "websocket", f"Unexpected ws scope type {scope['type']}" 306 | super().__init__(scope) 307 | self._receive = receive 308 | self._send = send 309 | self._client_state = CONNECTING # CONNECTING -> CONNECTED -> DISCONNECTED 310 | self._app_state = CONNECTING # CONNECTING -> CONNECTED -> DISCONNECTED 311 | 312 | async def accept(self, subprotocol=None): 313 | """Async function to accept the websocket connection. 314 | This needs to be called before any sending or receiving. 315 | Raises ``DisconnectedError`` when the client closed the connection. 316 | """ 317 | # If we haven't yet seen the 'connect' message, then wait for it first. 318 | if self._client_state == CONNECTING: 319 | message = await self._receive() 320 | mt = message["type"] 321 | if mt == "websocket.connect": 322 | self._client_state = CONNECTED 323 | elif mt == "websocket.disconnect": 324 | self._client_state = DISCONNECTED 325 | raise DisconnectedError() 326 | else: # pragma: no cover 327 | raise IOError(f"Unexpected ws message type {mt}") 328 | elif self._client_state == DISCONNECTED: 329 | raise IOError("Cannot accept ws that already disconnected.") 330 | # Accept from our side 331 | if self._app_state == CONNECTING: 332 | await self._send({"type": "websocket.accept", "subprotocol": subprotocol}) 333 | self._app_state = CONNECTED 334 | else: 335 | raise IOError("Cannot accept an already accepted ws connection.") 336 | 337 | async def send(self, data): 338 | """Async function to send a websocket message. The value can 339 | be ``bytes``, ``str`` or ``dict``. In the latter case, the message is 340 | encoded with JSON (and UTF-8). 341 | """ 342 | # Compose message 343 | if isinstance(data, bytes): 344 | message = {"type": "websocket.send", "bytes": data} 345 | elif isinstance(data, str): 346 | message = {"type": "websocket.send", "text": data} 347 | elif isinstance(data, dict): 348 | encoded = json.dumps(data).encode() 349 | message = {"type": "websocket.send", "bytes": encoded} 350 | else: 351 | raise TypeError(f"Can only send bytes/str/dict over ws, not {type(data)}") 352 | # Send it. In contrast to http, we cannot send after the client closed. 353 | if self._client_state == DISCONNECTED: 354 | raise IOError("Cannot send to a disconnected ws.") 355 | elif self._app_state == CONNECTED: 356 | await self._send(message) 357 | elif self._app_state == CONNECTING: 358 | raise IOError("Cannot send before calling accept on ws.") 359 | else: 360 | raise IOError("Cannot send to a closed ws.") 361 | 362 | async def receive(self): 363 | """Async function to receive one websocket message. The result can be 364 | ``bytes`` or ``str`` (depending on how it was sent). 365 | Raises ``DisconnectedError`` when the client closed the connection. 366 | """ 367 | # Get it 368 | if self._client_state == CONNECTED: 369 | message = await self._receive() 370 | elif self._client_state == DISCONNECTED: 371 | raise IOError("Cannot receive from ws that already disconnected.") 372 | else: 373 | raise IOError("Cannot receive before calling accept on ws.") 374 | # Process 375 | mt = message["type"] 376 | if mt == "websocket.receive": 377 | return message.get("bytes", None) or message.get("text", None) or b"" 378 | elif mt == "websocket.disconnect": 379 | self._client_state = DISCONNECTED 380 | raise DisconnectedError(f"ws disconnect {message.get('code', 1000)}") 381 | else: # pragma: no cover 382 | raise IOError(f"Unexpected ws message type {mt}") 383 | 384 | async def receive_iter(self): 385 | """Async generator to iterate over incoming messages as long 386 | as the connection is not closed. Each message can be a ``bytes`` or ``str``. 387 | """ 388 | while True: 389 | try: 390 | result = await self.receive() 391 | yield result 392 | except DisconnectedError: 393 | break 394 | 395 | async def receive_json(self): 396 | """Async convenience function to receive a JSON message. Works 397 | on binary as well as text messages, as long as its JSON encoded. 398 | Raises ``DisconnectedError`` when the client closed the connection. 399 | """ 400 | result = await self.receive() 401 | if isinstance(result, bytes): 402 | result = result.decode() 403 | return json.loads(result) 404 | 405 | async def close(self, code=1000): 406 | """Async function to close the websocket connection.""" 407 | await self._send({"type": "websocket.close", "code": code}) 408 | self._app_state = DISCONNECTED 409 | 410 | 411 | class RequestSet: 412 | """A set of request objects that are currenlty active. 413 | 414 | This class can help manage long-lived connections such as with long 415 | polling, SSE or websockets. All requests in as set can easily be 416 | awoken at once, and requests are automatically removed from the set 417 | when they're done. 418 | """ 419 | 420 | def __init__(self): 421 | self._s = weakref.WeakSet() 422 | 423 | def __len__(self): 424 | return len(self._s) 425 | 426 | def __iter__(self): 427 | return iter(self._s) 428 | 429 | def add(self, request): 430 | """Add a request object to the set.""" 431 | if not isinstance(request, BaseRequest): 432 | raise TypeError("RequestSet can only contain request objects.") 433 | request._request_sets.add(self) 434 | self._s.add(request) 435 | 436 | def discard(self, request): 437 | """Remove the given request object from the set. 438 | If not present, it is ignored. 439 | """ 440 | self._s.discard(request) 441 | 442 | def clear(self): 443 | """Remove all request objects from the set.""" 444 | self._s.clear() 445 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test behavior for HTTP request handlers. 3 | """ 4 | 5 | import json 6 | 7 | import pytest 8 | import asgineer 9 | 10 | from common import get_backend, make_server 11 | 12 | 13 | def test_backend_reporter(capsys=None): 14 | """A stub test to display the used backend.""" 15 | msg = f" Running tests with ASGI server: {get_backend()}" 16 | if capsys: 17 | with capsys.disabled(): 18 | print(msg) 19 | else: 20 | print(msg) 21 | 22 | 23 | ## Test normal usage 24 | 25 | 26 | async def handler1(request): 27 | return 200, {"xx-foo": "x"}, "hi!" 28 | 29 | 30 | async def handler2(request): 31 | async def handler1(request): 32 | return 200, {"xx-foo": "x"}, "hi!" 33 | 34 | return await handler1(request) 35 | 36 | 37 | async def handler3(request): 38 | async def handler2(request): 39 | return await handler1(request) 40 | 41 | return await handler2(request) 42 | 43 | 44 | async def handler4(request): 45 | return "ho!" 46 | 47 | 48 | async def handler5(request): 49 | return ("ho!",) 50 | 51 | 52 | async def handler6(request): 53 | return 400, "ho!" # Invalid 54 | 55 | 56 | async def handler7(request): 57 | return {"xx-foo": "x"}, "ho!" 58 | 59 | 60 | def test_normal_usage(): 61 | # Test normal usage 62 | 63 | with make_server(handler1) as p: 64 | res = p.get("/") 65 | 66 | print(res.status) 67 | print(res.headers) 68 | print(res.body) 69 | print(p.out) 70 | 71 | assert res.status == 200 72 | assert res.body.decode() == "hi!" 73 | assert not p.out 74 | 75 | # Daphne capitalizes the header keys, hypercorn aims at lowercase 76 | headers = set(k.lower() for k in res.headers.keys()) 77 | refheaders = {"content-type", "content-length", "server", "xx-foo"} 78 | ignoreheaders = {"connection", "date"} # "optional" 79 | assert headers.difference(ignoreheaders) == refheaders 80 | assert res.headers["content-type"] == "text/plain" 81 | assert res.headers["content-length"] == "3" # yes, a string 82 | 83 | # Test delegation to other handler 84 | 85 | with make_server(handler2) as p: 86 | res = p.get("/") 87 | 88 | assert res.status == 200 89 | assert res.body.decode() == "hi!" 90 | assert not p.out 91 | assert "xx-foo" in res.headers 92 | 93 | # Test delegation to yet other handler 94 | 95 | with make_server(handler3) as p: 96 | res = p.get("/") 97 | 98 | assert res.status == 200 99 | assert res.body.decode() == "hi!" 100 | assert not p.out 101 | assert "xx-foo" in res.headers 102 | 103 | 104 | def test_output_shapes(): 105 | # Singleton arg 106 | 107 | with make_server(handler4) as p: 108 | res = p.get("/") 109 | 110 | assert res.status == 200 111 | assert res.body.decode() == "ho!" 112 | assert not p.out 113 | 114 | with make_server(handler5) as p: 115 | res = p.get("/") 116 | 117 | assert res.status == 200 118 | assert res.body.decode() == "ho!" 119 | assert not p.out 120 | 121 | # Two element tuple (two forms, one is flawed) 122 | 123 | with make_server(handler6) as p: 124 | res = p.get("/") 125 | 126 | assert res.status == 500 127 | assert "Headers must be a dict" in res.body.decode() 128 | assert "Headers must be a dict" in p.out 129 | 130 | with make_server(handler7) as p: 131 | res = p.get("/") 132 | 133 | assert res.status == 200 134 | assert res.body.decode() == "ho!" 135 | assert not p.out 136 | assert "xx-foo" in res.headers 137 | 138 | 139 | def test_body_types(): 140 | # Plain text 141 | 142 | async def handler_text(request): 143 | return "ho!" 144 | 145 | with make_server(handler_text) as p: 146 | res = p.get("/") 147 | 148 | assert res.status == 200 149 | assert res.headers["content-type"] == "text/plain" 150 | assert res.body.decode() 151 | assert not p.out 152 | 153 | # Json 154 | 155 | async def handler_json1(request): 156 | return {"foo": 42, "bar": 7} 157 | 158 | with make_server(handler_json1) as p: 159 | res = p.get("/") 160 | 161 | assert res.status == 200 162 | assert res.headers["content-type"] == "application/json" 163 | assert json.loads(res.body.decode()) == {"foo": 42, "bar": 7} 164 | assert not p.out 165 | 166 | # Dicts can be non-jsonabe 167 | 168 | async def handler_json2(request): 169 | return {"foo": 42, "bar": b"x"} 170 | 171 | with make_server(handler_json2) as p: 172 | res = p.get("/") 173 | 174 | assert res.status == 500 175 | assert "could not json encode" in res.body.decode().lower() 176 | assert "could not json encode" in p.out.lower() 177 | 178 | # HTML 179 | 180 | async def handler_html1(request): 181 | return " foo" 182 | 183 | async def handler_html2(request): 184 | return "foo" 185 | 186 | with make_server(handler_html1) as p: 187 | res = p.get("/") 188 | 189 | assert res.status == 200 190 | assert res.headers["content-type"] == "text/html" 191 | assert "foo" in res.body.decode() 192 | assert not p.out 193 | 194 | with make_server(handler_html2) as p: 195 | res = p.get("/") 196 | 197 | assert res.status == 200 198 | assert res.headers["content-type"] == "text/html" 199 | assert "foo" in res.body.decode() 200 | assert not p.out 201 | 202 | # Explicit content type 203 | 204 | async def handler_explicit_content_type(request): 205 | return 200, {"Content-Type": "text/plain"}, b"hello, world" 206 | 207 | with make_server(handler_explicit_content_type) as p: 208 | res = p.get("/") 209 | 210 | assert res.status == 200 211 | assert res.headers["content-type"] == "text/plain" 212 | assert "Content-Type" not in res.headers 213 | assert "hello, world" == res.body.decode() 214 | assert not p.out 215 | 216 | 217 | ## Chunking 218 | 219 | 220 | def test_chunking(): 221 | # Write 222 | 223 | async def handler_chunkwrite1(request): 224 | async def asynciter(): 225 | yield "foo" 226 | yield "bar" 227 | 228 | return 200, {}, asynciter() 229 | 230 | with make_server(handler_chunkwrite1) as p: 231 | res = p.get("/") 232 | 233 | assert res.status == 200 234 | assert res.body.decode() == "foobar" 235 | assert not p.out 236 | 237 | # Read 238 | 239 | async def handler_chunkread1(request): 240 | body = [] 241 | async for chunk in request.iter_body(): 242 | body.append(chunk) 243 | return b"".join(body) 244 | 245 | with make_server(handler_chunkread1) as p: 246 | res = p.post("/", b"foobar") 247 | 248 | assert res.status == 200 249 | assert res.body.decode() == "foobar" 250 | assert not p.out 251 | 252 | # Read empty body 253 | 254 | async def handler_chunkread2(request): 255 | body = [] 256 | async for chunk in request.iter_body(): 257 | body.append(chunk) 258 | return b"".join(body) 259 | 260 | with make_server(handler_chunkread2) as p: 261 | res = p.post("/") 262 | 263 | assert res.status == 200 264 | assert res.body.decode() == "" 265 | assert not p.out 266 | 267 | # Both 268 | 269 | async def handler_chunkread3(request): 270 | return request.iter_body() # echo :) 271 | 272 | with make_server(handler_chunkread3) as p: 273 | res = p.post("/", b"foobar") 274 | 275 | assert res.status == 200 276 | assert res.body.decode() == "foobar" 277 | assert not p.out 278 | 279 | 280 | def test_chunking_fails(): 281 | # Write fail - cannot be regular generator 282 | 283 | async def handler_chunkwrite_fail1(request): 284 | def synciter(): 285 | yield "foo" 286 | yield "bar" 287 | 288 | return 200, {}, synciter() 289 | 290 | with make_server(handler_chunkwrite_fail1) as p: 291 | res = p.get("/") 292 | 293 | assert res.status == 500 294 | assert "cannot be a regular generator" in res.body.decode().lower() 295 | assert "cannot be a regular generator" in p.out.lower() 296 | 297 | # Write fail - cannot be (normal or async) func 298 | 299 | async def handler_chunkwrite_fail2(request): 300 | async def func(): 301 | return "foo" 302 | 303 | return 200, {}, func 304 | 305 | with make_server(handler_chunkwrite_fail2) as p: 306 | res = p.get("/") 307 | 308 | assert res.status == 500 309 | assert "body cannot be" in res.body.decode().lower() 310 | assert "body cannot be" in p.out.lower() 311 | 312 | # Read fail - cannot iter twice 313 | 314 | async def handler_chunkfail3(request): 315 | async for _chunk in request.iter_body(): 316 | pass 317 | async for _chunk in request.iter_body(): 318 | pass 319 | return "ok" 320 | 321 | with make_server(handler_chunkfail3) as p: 322 | res = p.post("/", b"x") 323 | 324 | assert res.status == 500 325 | assert "already consumed" in res.body.decode().lower() 326 | assert "already consumed" in p.out.lower() 327 | 328 | # Read fail - sleep_while_connected consumes data 329 | 330 | async def handler_chunkfail4(request): 331 | await request.sleep_while_connected(1.0) 332 | chunks = [] 333 | async for chunk in request.iter_body(): 334 | chunks.append(chunk) 335 | return b"".join(chunks) 336 | 337 | with make_server(handler_chunkfail4) as p: 338 | res = p.get("/", b"xx") 339 | 340 | assert res.status == 500 341 | assert "already consumed" in res.body.decode().lower() 342 | assert "already consumed" in p.out.lower() 343 | 344 | # Read fail - cannot iter after disconnect 345 | 346 | async def handler_chunkfail5(request): 347 | try: 348 | await request.sleep_while_connected(1.0) 349 | except asgineer.DisconnectedError: 350 | pass 351 | async for _chunk in request.iter_body(): 352 | pass 353 | return "ok" 354 | 355 | if get_backend() == "mock": 356 | with make_server(handler_chunkfail5) as p: 357 | res = p.post("/", b"x") 358 | 359 | assert res.status == 500 360 | assert "already disconnected" in res.body.decode().lower() 361 | assert "already disconnected" in p.out.lower() 362 | 363 | # Exceed memory 364 | 365 | async def handler_exceed_memory(request): 366 | await request.get_body(10) # 10 bytes 367 | return "ok" 368 | 369 | with make_server(handler_exceed_memory) as p: 370 | res = p.post("/", b"xxxxxxxxxx") 371 | 372 | assert res.status == 200 373 | 374 | with make_server(handler_exceed_memory) as p: 375 | res = p.post("/", b"xxxxxxxxxxx") 376 | 377 | assert res.status == 500 378 | assert "request body too large" in res.body.decode().lower() 379 | assert "request body too large" in p.out.lower() 380 | 381 | 382 | ## Test exceptions and errors 383 | 384 | 385 | async def handler_err1(request): 386 | return 501, {"xx-custom": "xx"}, "oops" 387 | 388 | 389 | async def handler_err2(request): 390 | raise ValueError("wo" + "ops") 391 | return 200, {"xx-custom": "xx"}, "oops" 392 | 393 | 394 | async def handler_err3(request): 395 | async def chunkiter(): 396 | raise ValueError("wo" + "ops") 397 | yield "foo" 398 | 399 | return 200, {"xx-custom": "xx"}, chunkiter() 400 | 401 | 402 | async def handler_err4(request): 403 | async def chunkiter(): 404 | yield "foo" 405 | raise ValueError("wo" + "ops") # too late to do a status 500 406 | 407 | return 200, {"xx-custom": "xx"}, chunkiter() 408 | 409 | 410 | def test_errors(): 411 | # Explicit error 412 | 413 | with make_server(handler_err1) as p: 414 | res = p.get("/") 415 | 416 | assert res.status == 501 417 | assert res.body.decode() == "oops" 418 | assert not p.out 419 | assert "xx-custom" in res.headers 420 | 421 | # Exception in handler 422 | 423 | with make_server(handler_err2) as p: 424 | res = p.get("/") 425 | 426 | assert res.status == 500 427 | assert "error in request handler" in res.body.decode().lower() 428 | assert "woops" in res.body.decode() 429 | assert "woops" in p.out 430 | assert p.out.count("ERROR") == 1 431 | assert p.out.count("woops") == 2 432 | assert "xx-custom" not in res.headers 433 | 434 | # Exception in handler with chunked body 435 | 436 | with make_server(handler_err3) as p: 437 | res = p.get("/") 438 | 439 | assert res.status == 500 440 | assert "error in sending chunked response" in res.body.decode().lower() 441 | assert "woops" in res.body.decode() 442 | assert "woops" in p.out and "foo" not in p.out 443 | assert "xx-custom" not in res.headers 444 | 445 | # Exception in handler with chunked body, too late 446 | 447 | with make_server(handler_err4) as p: 448 | res = p.get("/") 449 | 450 | assert res.status == 200 451 | assert res.body.decode() == "foo" 452 | assert "woops" in p.out 453 | assert "xx-custom" in res.headers 454 | 455 | 456 | ## Test wrong output 457 | 458 | 459 | async def handler_output1(request): 460 | return 200, {}, "foo", "bar" 461 | 462 | 463 | async def handler_output2(request): 464 | return 0 465 | 466 | 467 | async def handler_output3(request): 468 | return [200, {}, "foo"] 469 | 470 | 471 | async def handler_output4(request): 472 | return "200", {}, "foo" 473 | 474 | 475 | async def handler_output5(request): 476 | return 200, 4, "foo" 477 | 478 | 479 | async def handler_output6(request): 480 | return 200, {}, 4 481 | 482 | 483 | async def handler_output11(request): 484 | async def chunkiter(): 485 | yield 3 486 | yield "foo" 487 | 488 | return 200, {"xx-custom": "xx"}, chunkiter() 489 | 490 | 491 | async def handler_output12(request): 492 | async def chunkiter(): 493 | yield "foo" 494 | yield 3 # too late to do a status 500 495 | 496 | return 200, {"xx-custom": "xx"}, chunkiter() 497 | 498 | 499 | async def handler_output13(request): 500 | return handler1(request) # forgot await 501 | 502 | 503 | def test_wrong_output(): 504 | with make_server(handler_output1) as p: 505 | res = p.get("/") 506 | 507 | assert res.status == 500 508 | assert "handler returned 4-tuple" in res.body.decode().lower() 509 | assert "handler returned 4-tuple" in p.out.lower() 510 | 511 | for handler in ( 512 | handler_output2, 513 | handler_output3, 514 | handler_output6, 515 | handler_output13, 516 | ): 517 | with make_server(handler) as p: 518 | res = p.get("/") 519 | 520 | assert res.status == 500 521 | assert "body cannot be" in res.body.decode().lower() 522 | assert "body cannot be" in p.out.lower() 523 | 524 | with make_server(handler_output4) as p: 525 | res = p.get("/") 526 | 527 | assert res.status == 500 528 | assert "status code must be an int" in res.body.decode().lower() 529 | assert "status code must be an int" in p.out.lower() 530 | 531 | with make_server(handler_output5) as p: 532 | res = p.get("/") 533 | 534 | assert res.status == 500 535 | assert "headers must be a dict" in res.body.decode().lower() 536 | assert "headers must be a dict" in p.out.lower() 537 | 538 | # Chunked 539 | 540 | with make_server(handler_output11) as p: 541 | res = p.get("/") 542 | 543 | assert res.status == 500 544 | assert "error in sending chunked response" in res.body.decode().lower() 545 | assert "chunks must be" in res.body.decode().lower() 546 | assert "chunks must be" in p.out.lower() 547 | 548 | with make_server(handler_output12) as p: 549 | res = p.get("/") 550 | 551 | assert res.status == 200 # too late to set status! 552 | assert res.body.decode() == "foo" 553 | assert "chunks must be" in p.out.lower() 554 | 555 | # Wrong header 556 | 557 | async def wrong_header1(request): 558 | return 200, {"foo": 3}, b"" 559 | 560 | async def wrong_header2(request): 561 | return 200, {b"foo": "bar"}, b"" 562 | 563 | async def wrong_header3(request): 564 | return 200, {"foo": b"bar"}, b"" 565 | 566 | for handler in (wrong_header1, wrong_header2, wrong_header3): 567 | with make_server(handler) as p: 568 | res = p.get("/") 569 | 570 | assert res.status == 500 571 | assert "header keys and values" in res.body.decode().lower() 572 | assert "header keys and values" in p.out.lower() 573 | 574 | 575 | ## Test using accept and send 576 | 577 | 578 | def test_using_accept_and_send(): 579 | async def handler(request): 580 | await request.accept(200, {"xx-foo": "x"}) 581 | await request.send("hi!") 582 | 583 | with make_server(handler) as p: 584 | res = p.get("/") 585 | 586 | assert res.status == 200 587 | assert res.body.decode() == "hi!" 588 | assert not p.out 589 | 590 | 591 | def test_cannot_accept_and_return(): 592 | async def handler(request): 593 | await request.accept(200, {"xx-foo": "x"}) 594 | await request.send("hi!") 595 | return 200, {"xx-foo": "x"}, "hi!" 596 | 597 | with make_server(handler) as p: 598 | res = p.get("/") 599 | 600 | assert res.status == 200 # Because response has already been sent! 601 | assert res.body.decode() == "hi!" 602 | assert "should return None" in p.out 603 | 604 | 605 | def test_cannot_accept_twice(): 606 | async def handler(request): 607 | await request.accept(200, {"xx-foo": "x"}) 608 | await request.accept(200, {"xx-foo": "x"}) 609 | await request.send("hi!") 610 | 611 | with make_server(handler) as p: 612 | res = p.get("/") 613 | 614 | assert res.status == 200 # accept was already sent 615 | assert res.body.decode() == "" # but body was not 616 | assert "cannot accept" in p.out.lower() 617 | 618 | 619 | def test_cannot_send_wrong_objects(): 620 | async def handler(request): 621 | await request.accept(200, {"xx-foo": "x"}) 622 | await request.send({"foo": "bar"}) 623 | 624 | with make_server(handler) as p: 625 | res = p.get("/") 626 | 627 | assert res.status == 200 # accept was already sent 628 | assert res.body.decode() == "" 629 | assert "can only send" in p.out.lower() 630 | 631 | 632 | def test_cannot_send_before_accept(): 633 | async def handler(request): 634 | await request.send("hi!") 635 | await request.accept(200, {"xx-foo": "x"}) 636 | 637 | with make_server(handler) as p: 638 | res = p.get("/") 639 | 640 | assert res.status == 500 641 | assert "cannot send before" in res.body.decode().lower() 642 | assert "cannot send before" in p.out.lower() 643 | 644 | 645 | def test_cannot_send_after_closing(): 646 | async def handler(request): 647 | await request.accept(200, {"xx-foo": "x"}) 648 | await request.send("hi!", more=False) 649 | await request.send("hi!") 650 | 651 | with make_server(handler) as p: 652 | res = p.get("/") 653 | 654 | assert res.status == 200 655 | assert res.body.decode() == "hi!" 656 | assert "cannot send to a closed" in p.out.lower() 657 | 658 | 659 | ## Test wrong usage 660 | 661 | 662 | def handler_wrong_use1(request): 663 | return 200, {}, "hi" 664 | 665 | 666 | async def handler_wrong_use2(request): 667 | yield 200, {}, "hi" 668 | 669 | 670 | def test_wrong_use(): 671 | with pytest.raises(TypeError): 672 | asgineer.to_asgi(handler_wrong_use1) 673 | 674 | with pytest.raises(TypeError): 675 | asgineer.to_asgi(handler_wrong_use2) 676 | 677 | 678 | ## 679 | 680 | if __name__ == "__main__": 681 | from common import run_tests, set_backend_from_argv 682 | 683 | set_backend_from_argv() 684 | run_tests(globals()) 685 | 686 | # with make_server(handler_err2) as p: 687 | # time.sleep(10) 688 | -------------------------------------------------------------------------------- /asgineer/testutils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Asgineer test utilities. 3 | """ 4 | 5 | import os 6 | import sys 7 | import time 8 | import inspect 9 | import asyncio 10 | import tempfile 11 | import subprocess 12 | from collections import namedtuple 13 | from wsgiref.handlers import format_date_time 14 | from urllib.parse import unquote, urlparse 15 | 16 | import requests 17 | 18 | import asgineer 19 | 20 | 21 | Response = namedtuple("Response", ["status", "headers", "body"]) 22 | 23 | testfilename = os.path.join( 24 | tempfile.gettempdir(), f"asgineer_test_script_{os.getpid()}.py" 25 | ) 26 | 27 | PORT = 49152 + os.getpid() % 16383 # hash pid to ephimeral port number 28 | URL = f"http://127.0.0.1:{PORT}" 29 | 30 | 31 | # todo: allow running multiple processes at the same time, by including a sequence number 32 | 33 | 34 | class BaseTestServer: 35 | """Base class for test servers. Objects of this class represent an ASGI 36 | server instance that can be used to test your server implementation. 37 | 38 | The ``app`` object passed to the constructor can be an ASGI application 39 | or an async (Asgineer-style) handler. 40 | 41 | The server can be started/stopped by using it as a context manager. 42 | The ``url`` attribute represents the url that can be used to make 43 | requests to the server. When the server has stopped, The ``out`` 44 | attribute contains the server output (stdout and stderr). 45 | 46 | Only one instance of this class (per process) should be used (as a 47 | context manager) at any given time. 48 | """ 49 | 50 | def __init__(self, app, server_description, *, loop=None): 51 | self._app = app 52 | self._server = server_description 53 | self._loop = asyncio.new_event_loop() if loop is None else loop 54 | self._out = "" 55 | # Get stdout funcs because the mock server hijacks them 56 | self._stdout_write = sys.stdout.write 57 | self._stdout_flush = sys.stdout.flush 58 | 59 | @property 60 | def app(self): 61 | """The application object that was given at instantiation.""" 62 | return self._app 63 | 64 | @property 65 | def url(self): 66 | """The url at which the server is listening.""" 67 | return URL 68 | 69 | @property 70 | def out(self): 71 | """The stdout / stderr of the server. This gets set when the 72 | with-statement using this object exits. 73 | """ 74 | return self._out 75 | 76 | def __enter__(self): 77 | self.log(f" Create {self._server} server .. ", end="") 78 | self._out = "" 79 | t0 = time.time() 80 | 81 | self._start_server() 82 | 83 | self.log(f" {time.time() - t0:0.1f}s ", end="") 84 | return self 85 | 86 | def __exit__(self, exc_type, exc_value, traceback): 87 | self.log("- Closing .. " if exc_value is None else "Error .. ", end="") 88 | t0 = time.time() 89 | 90 | out = self._stop_server() 91 | self._out = "\n".join(self.filter_lines(out.splitlines())) 92 | 93 | if exc_value is None: 94 | self.log(f" {time.time() - t0:0.1f}s ") 95 | else: 96 | self.log("Process output:") 97 | self.log(self.out) 98 | 99 | def get(self, path, data=None, headers=None, **kwargs): 100 | """Send a GET request to the server. See request() for detais.""" 101 | return self.request("GET", path, data=data, headers=headers, **kwargs) 102 | 103 | def put(self, path, data=None, headers=None, **kwargs): 104 | """Send a PUT request to the server. See request() for detais.""" 105 | return self.request("PUT", path, data=data, headers=headers, **kwargs) 106 | 107 | def post(self, path, data=None, headers=None, **kwargs): 108 | """Send a POST request to the server. See request() for detais.""" 109 | return self.request("POST", path, data=data, headers=headers, **kwargs) 110 | 111 | def delete(self, path, data=None, headers=None, **kwargs): 112 | """Send a DELETE request to the server. See request() for detais.""" 113 | return self.request("DELETE", path, data=data, headers=headers, **kwargs) 114 | 115 | def request(self, method, path, data=None, headers=None, **kwargs): 116 | """Send a request to the server. Returns a named tuple ``(status, headers, body)``. 117 | 118 | Arguments: 119 | method (str): the HTTP method (e.g. "GET") 120 | path (str): path or url (also see the ``url`` property). 121 | data: the bytes to send (optional). 122 | headers: headers to send (optional). 123 | kwargs: additional arguments to pass to ``requests.request()``. 124 | 125 | """ 126 | assert isinstance(method, str) 127 | assert isinstance(path, str) 128 | if path.startswith("http"): 129 | url = path 130 | else: 131 | url = self.url + "/" + path.lstrip("/") 132 | 133 | co = self._co_request(method, url, data=data, headers=headers, **kwargs) 134 | co_res = self._loop.run_until_complete(co) 135 | status, headers, body = co_res 136 | return Response(status, headers, body) 137 | 138 | def ws_communicate(self, path, client_co_func, loop=None): 139 | """Do a websocket request and communicate over the connection. 140 | 141 | The ``client_co_func`` object must be an async function, it receives 142 | a ws object as an argument, which has methods ``send``, ``receive`` and 143 | ``close``, and it can be iterated over. Messages are either str or bytes. 144 | """ 145 | url = self.url.replace("http", "ws") + "/" + path.lstrip("/") 146 | if loop is None: 147 | loop = asyncio.new_event_loop() 148 | co = self._co_ws_communicate(url, client_co_func, loop) 149 | return loop.run_until_complete(co) 150 | 151 | def log(self, *messages, sep=" ", end="\n"): 152 | """Log a message. Overloadable. Default write to stdout.""" 153 | msg = sep.join(str(m) for m in messages) 154 | self._stdout_write(msg + end) 155 | self._stdout_flush() 156 | 157 | def filter_lines(self, lines): 158 | """Overloadable line filter.""" 159 | return lines 160 | 161 | 162 | START_CODE = """ 163 | import os 164 | import sys 165 | import time 166 | import threading 167 | import _thread 168 | 169 | import asgineer 170 | 171 | def closer(): 172 | while os.path.isfile(__file__): 173 | time.sleep(0.01) 174 | _thread.interrupt_main() 175 | 176 | app = APP 177 | 178 | 179 | async def proxy_app(scope, receive, send): 180 | if scope["path"].startswith("/specialtestpath/"): 181 | await send({"type": "http.response.start", "status": 200, "headers": []}) 182 | await send({"type": "http.response.body", "body": b""}) 183 | else: 184 | return await app(scope, receive, send) 185 | 186 | if __name__ == "__main__": 187 | threading.Thread(target=closer).start() 188 | asgineer.run("__main__:proxy_app", "ASGISERVER", "localhost:PORT") 189 | sys.stderr.flush() 190 | sys.stdout.flush() 191 | sys.exit(0) 192 | """ 193 | 194 | LOAD_MODULE_CODE = """ 195 | from importlib.util import spec_from_file_location 196 | def load_module(name, filename): 197 | assert filename.endswith('.py') 198 | if name in sys.modules: 199 | return sys.modules[name] 200 | if '.' in name: 201 | load_module(name.rsplit('.', 1)[0], os.path.join(os.path.dirname(filename), '__init__.py')) 202 | spec = spec_from_file_location(name, filename) 203 | return spec.loader.load_module() 204 | """ 205 | 206 | 207 | class ProcessTestServer(BaseTestServer): 208 | """Subclass of BaseTestServer that runs an actual server in a 209 | subprocess. The ``server`` argument must be a server supported by 210 | Asgineer' ``run()`` function, like "uvicorn", "hypercorn" or "daphne". 211 | 212 | This provides a very realistic approach to test server applicationes, though 213 | the overhead of starting and stopping the server costs about a second, 214 | and its hard to measure code coverage in this way. Therefore this approach 215 | is most suited for higher level / integration tests. 216 | 217 | Requests can be done via the methods of this object, or using any other 218 | request library. 219 | """ 220 | 221 | def __init__(self, app, server, **kwargs): 222 | super().__init__(app, server, **kwargs) 223 | self._app_code = self._get_app_code(app) 224 | 225 | def _get_app_code(self, app): 226 | assert app.__code__.co_argcount in (1, 3) 227 | mod = inspect.getmodule(app) 228 | modname = "_main_" if mod.__name__ == "__main__" else mod.__name__ 229 | is_handler = app.__code__.co_argcount == 1 230 | name1 = app.__name__ 231 | name2 = "handler" if is_handler else "app" 232 | 233 | if getattr(mod, name1, None) is app: 234 | # We can import the app - safest option since app may have deps 235 | code = LOAD_MODULE_CODE 236 | code += "sys.path.insert(0, '')\n" + code 237 | if "." not in mod.__name__: 238 | code += f"sys.path.insert(0, {os.path.dirname(mod.__file__)!r})\n" 239 | code += f"{name2} = load_module({modname!r}, {mod.__file__!r}).{name1}" 240 | 241 | else: 242 | # Likely a app defined inside a function. Get app from sourece code. 243 | # This will not work if the app has dependencies. 244 | sourcelines = inspect.getsourcelines(app)[0] 245 | indent = inspect.indentsize(sourcelines[0]) 246 | code = "\n".join(line[indent:] for line in sourcelines) 247 | code = code.replace("def " + app.__name__, f"def {name2}") 248 | 249 | if is_handler: 250 | code += f"\napp = asgineer.to_asgi({name2})" 251 | return code 252 | 253 | def _start_server(self): 254 | # Prepare code 255 | code = START_CODE.replace("ASGISERVER", self._server).replace("PORT", str(PORT)) 256 | code = code.replace("app = APP", self._app_code) 257 | with open(testfilename, "wb") as f: 258 | f.write((code).encode()) 259 | # Start server, clean up the temp filename on failure since __exit__ wont be called. 260 | try: 261 | self._start_subprocess() 262 | except Exception as err: 263 | self._delfile() 264 | raise err 265 | 266 | def _start_subprocess(self): 267 | # Start subprocess. Don't use stdin; it breaks multiprocessing somehow! 268 | self._p = subprocess.Popen( 269 | [sys.executable, testfilename], 270 | stdout=subprocess.PIPE, 271 | stderr=subprocess.STDOUT, 272 | ) 273 | # Wait for process to start, and make sure it is not dead 274 | while self._p.poll() is None: 275 | time.sleep(0.02) 276 | try: 277 | requests.get(URL + "/specialtestpath/init", timeout=0.01) 278 | break 279 | except (requests.ConnectionError, requests.ReadTimeout): 280 | pass 281 | if self._p.poll() is not None: 282 | raise RuntimeError( 283 | "Process failed to start!\n" + self._p.stdout.read().decode() 284 | ) 285 | 286 | def _stop_server(self): 287 | # Ask process to stop 288 | self._delfile() 289 | # Force it to stop if needed 290 | for _ in range(5): 291 | etime = time.time() + 5 292 | while self._p.poll() is None and time.time() < etime: 293 | time.sleep(0.01) 294 | if self._p.poll() is not None: 295 | break 296 | self._p.terminate() 297 | else: 298 | raise RuntimeError("Runaway server process failed to terminate!") 299 | if self._p.poll(): 300 | self.log(f"nonzero exit code {self._p.poll()}") 301 | # Get output 302 | return self._p.stdout.read().decode(errors="ignore") 303 | 304 | def _delfile(self): 305 | try: 306 | os.remove(testfilename) 307 | except Exception: 308 | pass 309 | 310 | async def _co_request(self, method, url, **kwargs): 311 | r = requests.request(method, url, **kwargs) 312 | # `requests` conveniently presents headers in a `CaseInsensitiveDict`, which is great for 313 | # normal usage, but for consistency with MockTestServer, we convert this to a regular dict 314 | # with lowercase keys. 315 | case_sensitive_headers = {k.lower(): v for k, v in r.headers.items()} 316 | return r.status_code, case_sensitive_headers, r.content 317 | 318 | async def _co_ws_communicate(self, url, client_co_func, loop): 319 | import websockets 320 | 321 | try: 322 | ws = await websockets.connect(url) 323 | except (websockets.InvalidStatus, websockets.InvalidStatusCode): 324 | return None 325 | ws.receive = ws.recv 326 | res = await client_co_func(ws) 327 | await ws.close() 328 | return res 329 | 330 | 331 | class MockTestServer(BaseTestServer): 332 | """Subclass of BaseTestServer that mocks an ASGI server and 333 | operates in-process. This is a less realistic approach, but faster 334 | and allows tracking test coverage, so it's more suited for unit 335 | tests. 336 | 337 | Requests *must* be done via the methods of this object. The used url 338 | can be anything. 339 | """ 340 | 341 | def __init__(self, app, **kwargs): 342 | super().__init__(app, "mock", **kwargs) 343 | 344 | if app.__code__.co_argcount == 3: 345 | self._asgi_app = app 346 | else: 347 | self._asgi_app = asgineer.to_asgi(app) 348 | 349 | self._out_writes = [] 350 | 351 | def _write(self, msg): 352 | self._out_writes.append(msg) 353 | 354 | def _start_server(self): 355 | self._out_writes = [] 356 | self._ori_streams = sys.stdout.write, sys.stderr.write 357 | sys.stdout.write = sys.stderr.write = self._write 358 | 359 | try: 360 | self._lifespan_messages = [] 361 | self._lifespan_completes = [] 362 | self._lifespan_task = self._make_lifespan_task() 363 | self._wait_for_lifespan_complete("startup") 364 | except Exception as err: 365 | self._restore_streams() 366 | raise err 367 | 368 | def _restore_streams(self): 369 | sys.stdout.write, sys.stderr.write = self._ori_streams 370 | 371 | def _stop_server(self): 372 | try: 373 | self._wait_for_lifespan_complete("shutdown") 374 | except Exception as err: 375 | self._restore_streams() 376 | raise err 377 | else: 378 | self._restore_streams() 379 | return "".join(self._out_writes) 380 | 381 | def _make_lifespan_task(self): 382 | scope = {"type": "lifespan"} 383 | 384 | async def receive(): 385 | while True: 386 | if self._lifespan_messages: 387 | return self._lifespan_messages.pop(0) 388 | await asyncio.sleep(0.02) 389 | 390 | async def send(m): 391 | self._lifespan_completes.append(m["type"]) 392 | 393 | return self._loop.create_task(self._asgi_app(scope, receive, send)) 394 | 395 | def _wait_for_lifespan_complete(self, what, timeout=5): 396 | what_complete = f"lifespan.{what}.complete" 397 | 398 | async def waiter(): 399 | etime = time.time() + timeout 400 | while what_complete not in self._lifespan_completes: 401 | if self._lifespan_task.done(): 402 | raise RuntimeError( 403 | f"Lifespan task finished without producing {what}" 404 | ) 405 | if time.time() > etime: 406 | raise RuntimeError( 407 | f"Timeout for {what}, has {self._lifespan_completes}" 408 | ) 409 | await asyncio.sleep(0.02) 410 | 411 | self._lifespan_messages.append({"type": f"lifespan.{what}"}) 412 | self._loop.run_until_complete(waiter()) 413 | 414 | def _make_scope(self, request): 415 | scheme, netloc, path, _params, query, _fragment = urlparse(request.url) 416 | if ":" in netloc: 417 | host, port = netloc.split(":", 1) 418 | port = int(port) 419 | else: 420 | host = netloc 421 | port = {"http": 80, "ws": 80, "https": 443, "wss": 443}[scheme] 422 | 423 | # Include the 'host' header. 424 | if "host" in request.headers: 425 | headers = [] 426 | elif port == 80: 427 | headers = [[b"host", host.encode()]] 428 | else: 429 | headers = [[b"host", ("%s:%d" % (host, port)).encode()]] 430 | 431 | # Include other request headers. 432 | headers += [ 433 | [key.lower().encode(), value.encode()] 434 | for key, value in request.headers.items() 435 | ] 436 | 437 | if scheme.startswith("http"): 438 | return { 439 | "type": "http", 440 | "http_version": "1.1", 441 | "method": request.method, 442 | "scheme": scheme, 443 | "path": unquote(path), 444 | "root_path": "", 445 | "query_string": query.encode(), 446 | "headers": headers, 447 | "client": ["testclient", 50000], 448 | "server": [host, port], 449 | } 450 | elif scheme.startswith("ws"): 451 | return { 452 | "type": "websocket", 453 | "scheme": scheme, 454 | "path": unquote(path), 455 | "root_path": "", 456 | "query_string": query.encode(), 457 | "headers": headers, 458 | "client": ["testclient", 50000], 459 | "server": [host, port], 460 | "subprotocols": [], 461 | } 462 | else: 463 | raise RuntimeError(f"Unknown scheme: {scheme}") 464 | 465 | async def _co_request(self, method, url, **kwargs): 466 | req = requests.Request(method, url, **kwargs) 467 | p = req.prepare() # Get the "resolved" request 468 | p.headers.setdefault("user-agent", "asgi_mock_server") 469 | scope = self._make_scope(p) 470 | 471 | # --- 472 | 473 | client_to_server = [] 474 | server_to_client = [] 475 | if p.body is not None: 476 | client_to_server.append(p.body) 477 | else: 478 | client_to_server.append(b"") 479 | 480 | async def receive(): 481 | if client_to_server: 482 | chunk = client_to_server.pop(0) 483 | return { 484 | "type": "http.request", 485 | "body": chunk, 486 | "more_body": bool(client_to_server), 487 | } 488 | else: 489 | if method == "GET": 490 | # We wait ... this is us mimicking an open connection 491 | await asyncio.sleep(9999) 492 | elif method == "PUT": 493 | return {"type": "http.disconnect"} 494 | else: 495 | # Let's be a bad server and return None instead 496 | return None 497 | 498 | async def send(m): 499 | if m["type"] == "http.response.start": 500 | headers = dict((h[0].decode(), h[1].decode()) for h in m["headers"]) 501 | headers.setdefault("date", format_date_time(time.time())) 502 | headers.setdefault("server", "asgineer_mock_server") 503 | response.extend([m["status"], headers]) 504 | elif m["type"] == "http.response.body": 505 | server_to_client.append(m["body"]) 506 | else: 507 | pass # ignore? 508 | 509 | response = [] 510 | await self._asgi_app(scope, receive, send) 511 | if not response: 512 | response.extend([9999, {}]) 513 | response.append(b"".join(server_to_client)) 514 | 515 | return tuple(response) 516 | 517 | async def _co_ws_communicate(self, url, client_co_func, loop): 518 | req = requests.Request("GET", url) 519 | p = req.prepare() # Get the "resolved" request 520 | p.headers.setdefault("user-agent", "asgi_mock_server") 521 | scope = self._make_scope(p) 522 | 523 | # --- 524 | 525 | client_to_server = [] 526 | server_to_client = [] 527 | 528 | async def receive(): 529 | while not client_to_server: 530 | await asyncio.sleep(0.02) 531 | return client_to_server.pop(0) 532 | 533 | async def send(m): 534 | server_to_client.append(m) 535 | 536 | class WS: 537 | def __init__(self): 538 | self._closed_server = False 539 | self._accepted = False 540 | 541 | async def send(self, value): 542 | if self._closed_server: 543 | raise IOError("ConnectionClosed") 544 | if isinstance(value, bytes): 545 | m = {"type": "websocket.receive", "bytes": value} 546 | elif isinstance(value, str): 547 | m = {"type": "websocket.receive", "text": value} 548 | else: 549 | raise TypeError("Can only send bytes/str.") 550 | client_to_server.append(m) 551 | 552 | async def receive(self): 553 | # Wait for message to become available 554 | if self._closed_server: 555 | raise IOError("WS is closed") 556 | while not server_to_client: 557 | await asyncio.sleep(0.02) 558 | # Get message and handle special cases 559 | m = server_to_client.pop(0) 560 | if m["type"] in ("websocket.disconnect", "websocket.close"): 561 | self._closed_server = True 562 | raise IOError("WS closed") 563 | if m["type"] == "websocket.accept": 564 | self._accepted = True 565 | return await self.receive() 566 | # Return 567 | return m.get("bytes", None) or m.get("text", None) or b"" 568 | 569 | async def close(self): 570 | client_to_server.append({"type": "websocket.disconnect"}) 571 | 572 | async def __aiter__(self): 573 | while True: 574 | try: 575 | yield await self.receive() 576 | except IOError: 577 | return 578 | 579 | loop.create_task(self._asgi_app(scope, receive, send)) 580 | client_to_server.append({"type": "websocket.connect"}) 581 | ws = WS() 582 | result = await client_co_func(ws) 583 | client_to_server.append({"type": "websocket.disconnect"}) 584 | return result 585 | --------------------------------------------------------------------------------