├── 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 | [](https://github.com/almarklein/asgineer/actions)
5 | [](https://asgineer.readthedocs.io/?badge=latest)
6 | [](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 |
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 |
161 |
162 |
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 |
--------------------------------------------------------------------------------