pax_global_header00006660000000000000000000000064144106223130014506gustar00rootroot0000000000000052 comment=fcb318f2f1c41302f73464daa74478289d6731f4 florimondmanca-asgi-lifespan-fcb318f/000077500000000000000000000000001441062231300177155ustar00rootroot00000000000000florimondmanca-asgi-lifespan-fcb318f/.gitignore000066400000000000000000000002151441062231300217030ustar00rootroot00000000000000# Tooling. .coverage venv*/ .python-version # Caches. __pycache__/ *.pyc .mypy_cache/ .pytest_cache/ # Packaging. build/ dist/ *.egg-info/ florimondmanca-asgi-lifespan-fcb318f/CHANGELOG.md000066400000000000000000000053111441062231300215260ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## 2.1.0 - 2023-03-28 ### Added - Add support for lifespan state. (Pull #59) ## 2.0.0 - 2022-11-11 ### Removed - Drop support for Python 3.6. (Pull #55) ### Added - Add official support for Python 3.11. (Pull #55) - Add official support for Python 3.9 and 3.10. (Pull #46 - Thanks @euri10) ### Fixed - Ensure compatibility with mypy 0.990+, which made `no_implicit_optional` the default. (Pull #53 - Thanks @AllSeeingEyeTolledEweSew) ## 1.0.1 - 2020-06-08 ### Fixed - Update development status to `5 - Production/Stable`. (Pull #32) ## 1.0.0 - 2020-02-02 ### Removed - Drop `Lifespan` and `LifespanMiddleware`. Please use Starlette's built-in lifespan capabilities instead. (Pull #27) ### Fixed - Use `sniffio` for auto-detecting the async environment. (Pull #28) - Enforce 100% test coverage on CI. (Pull #29) ### Changed - Enforce importing from the top-level package by switching to private internal modules. (Pull #26) ## 0.6.0 - 2019-11-29 ### Changed - Move `Lifespan` to the `lifespan` module. (Pull #21) - Refactor `LifespanManager` to drop dependency on `asynccontextmanager` on 3.6. (Pull #20) ## 0.5.0 - 2019-11-29 - Enter Beta development status. ### Removed - Remove `curio` support. (Pull #18) ### Added - Ship binary distributions (wheels) alongside source distributions. ### Changed - Use custom concurrency backends instead of `anyio` for asyncio and trio support. (Pull #18) ## 0.4.2 - 2019-10-06 ### Fixed - Ensure `py.typed` is bundled with the package so that type checkers can detect type annotations. (Pull #16) ## 0.4.1 - 2019-09-29 ### Fixed - Improve error handling in `LifespanManager` (Pull #11): - Exceptions raised in the context manager body or during shutdown are now properly propagated. - Unsupported lifespan is now also detected when the app calls `send()` before calling having called `receive()` at least once. ## 0.4.0 - 2019-09-29 - Enter Alpha development status. ## 0.3.1 - 2019-09-29 ### Added - Add configurable timeouts to `LifespanManager`. (Pull #10) ## 0.3.0 - 2019-09-29 ### Added - Add `LifespanManager` for sending lifespan events into an ASGI app. (Pull #5) ## 0.2.0 - 2019-09-28 ### Added - Add `LifespanMiddleware`, an ASGI middleware to add lifespan support to an ASGI app. (Pull #9) ## 0.1.0 - 2019-09-28 ### Added - Add `Lifespan`, an ASGI app implementing the lifespan protocol with event handler registration support. (Pull #7) ## 0.0.2 - 2019-09-28 ### Fixed - Installation from PyPI used to fail due to missing `MANIFEST.in`. ## 0.0.1 - 2019-09-28 ### Added - Empty package. florimondmanca-asgi-lifespan-fcb318f/LICENSE000066400000000000000000000020601441062231300207200ustar00rootroot00000000000000MIT License Copyright (c) 2019 Florimond Manca Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. florimondmanca-asgi-lifespan-fcb318f/MANIFEST.in000066400000000000000000000001011441062231300214430ustar00rootroot00000000000000graft src include README.md include CHANGELOG.md include LICENSE florimondmanca-asgi-lifespan-fcb318f/Makefile000066400000000000000000000011021441062231300213470ustar00rootroot00000000000000venv = venv bin = ${venv}/bin/ pysources = src/ tests/ build: ${bin}python -m build check: ${bin}black --check --diff ${pysources} ${bin}flake8 ${pysources} ${bin}mypy ${pysources} ${bin}isort --check --diff ${pysources} install: install-python venv: python3 -m venv ${venv} install-python: venv ${bin}pip install -U pip wheel ${bin}pip install -U build ${bin}pip install -r requirements.txt format: ${bin}autoflake --in-place --recursive ${pysources} ${bin}isort ${pysources} ${bin}black ${pysources} publish: ${bin}twine upload dist/* test: ${bin}pytest florimondmanca-asgi-lifespan-fcb318f/README.md000066400000000000000000000154031441062231300211770ustar00rootroot00000000000000# asgi-lifespan [![Build Status](https://dev.azure.com/florimondmanca/public/_apis/build/status/florimondmanca.asgi-lifespan?branchName=master)](https://dev.azure.com/florimondmanca/public/_build?definitionId=12) [![Coverage](https://codecov.io/gh/florimondmanca/asgi-lifespan/branch/master/graph/badge.svg)](https://codecov.io/gh/florimondmanca/asgi-lifespan) [![Package version](https://badge.fury.io/py/asgi-lifespan.svg)](https://pypi.org/project/asgi-lifespan) Programmatically send startup/shutdown [lifespan](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) events into [ASGI](https://asgi.readthedocs.io) applications. When used in combination with an ASGI-capable HTTP client such as [HTTPX](https://www.python-httpx.org), this allows mocking or testing ASGI applications without having to spin up an ASGI server. ## Features - Send lifespan events to an ASGI app using `LifespanManager`. - Support for [`asyncio`](https://docs.python.org/3/library/asyncio) and [`trio`](https://trio.readthedocs.io). - Fully type-annotated. - 100% test coverage. ## Installation ```bash pip install 'asgi-lifespan==2.*' ``` ## Usage `asgi-lifespan` provides a `LifespanManager` to programmatically send ASGI lifespan events into an ASGI app. This can be used to programmatically startup/shutdown an ASGI app without having to spin up an ASGI server. `LifespanManager` can run on either `asyncio` or `trio`, and will auto-detect the async library in use. ### Basic usage ```python # example.py from contextlib import asynccontextmanager from asgi_lifespan import LifespanManager from starlette.applications import Starlette # Example lifespan-capable ASGI app. Any ASGI app that supports # the lifespan protocol will do, e.g. FastAPI, Quart, Responder, ... @asynccontextmanager async def lifespan(app): print("Starting up!") yield print("Shutting down!") app = Starlette(lifespan=lifespan) async def main(): async with LifespanManager(app) as manager: print("We're in!") # On asyncio: import asyncio; asyncio.run(main()) # On trio: # import trio; trio.run(main) ``` Output: ```console $ python example.py Starting up! We're in! Shutting down! ``` ### Sending lifespan events for testing The example below demonstrates how to use `asgi-lifespan` in conjunction with [HTTPX](https://www.python-httpx.org) and `pytest` in order to send test requests into an ASGI app. - Install dependencies: ``` pip install asgi-lifespan httpx starlette pytest pytest-asyncio ``` - Test script: ```python # test_app.py from contextlib import asynccontextmanager import httpx import pytest import pytest_asyncio from asgi_lifespan import LifespanManager from starlette.applications import Starlette from starlette.responses import PlainTextResponse from starlette.routing import Route @pytest_asyncio.fixture async def app(): @asynccontextmanager async def lifespan(app): print("Starting up") yield print("Shutting down") async def home(request): return PlainTextResponse("Hello, world!") app = Starlette( routes=[Route("/", home)], lifespan=lifespan, ) async with LifespanManager(app) as manager: print("We're in!") yield manager.app @pytest_asyncio.fixture async def client(app): async with httpx.AsyncClient(app=app, base_url="http://app.io") as client: print("Client is ready") yield client @pytest.mark.asyncio async def test_home(client): print("Testing") response = await client.get("/") assert response.status_code == 200 assert response.text == "Hello, world!" print("OK") ``` - Run the test suite: ```console $ pytest -s test_app.py ======================= test session starts ======================= test_app.py Starting up We're in! Client is ready Testing OK .Shutting down ======================= 1 passed in 0.88s ======================= ``` ### Accessing state `LifespanManager` provisions a [lifespan state](https://asgi.readthedocs.io/en/latest/specs/lifespan.html#lifespan-state) which persists data from the lifespan cycle for use in request/response handling. For your app to be aware of it, be sure to use `manager.app` instead of the `app` itself when inside the context manager. For example if using HTTPX as an async test client: ```python async with LifespanManager(app) as manager: async with httpx.AsyncClient(app=manager.app) as client: ... ``` ## API Reference ### `LifespanManager` ```python def __init__( self, app: Callable, startup_timeout: Optional[float] = 5, shutdown_timeout: Optional[float] = 5, ) ``` An [asynchronous context manager](https://docs.python.org/3/reference/datamodel.html#async-context-managers) that starts up an ASGI app on enter and shuts it down on exit. More precisely: - On enter, start a `lifespan` request to `app` in the background, then send the `lifespan.startup` event and wait for the application to send `lifespan.startup.complete`. - On exit, send the `lifespan.shutdown` event and wait for the application to send `lifespan.shutdown.complete`. - If an exception occurs during startup, shutdown, or in the body of the `async with` block, it bubbles up and no shutdown is performed. **Example** ```python async with LifespanManager(app) as manager: # 'app' was started up. ... # 'app' was shut down. ``` **Parameters** - `app` (`Callable`): an ASGI application. - `startup_timeout` (`Optional[float]`, defaults to 5): maximum number of seconds to wait for the application to startup. Use `None` for no timeout. - `shutdown_timeout` (`Optional[float]`, defaults to 5): maximum number of seconds to wait for the application to shutdown. Use `None` for no timeout. **Yields** - `manager` (`LifespanManager`): the `LifespanManager` itself. In case you use [lifespan state](https://asgi.readthedocs.io/en/latest/specs/lifespan.html#lifespan-state), use `async with LifespanManager(app) as manager: ...` then access `manager.app` to get a reference to the state-aware app. **Raises** - `LifespanNotSupported`: if the application does not seem to support the lifespan protocol. Based on the rationale that if the app supported the lifespan protocol then it would successfully receive the `lifespan.startup` ASGI event, unsupported lifespan protocol is detected in two situations: - The application called `send()` before calling `receive()` for the first time. - The application raised an exception during startup before making its first call to `receive()`. For example, this may be because the application failed on a statement such as `assert scope["type"] == "http"`. - `TimeoutError`: if startup or shutdown timed out. - `Exception`: any exception raised by the application (during startup, shutdown, or within the `async with` body) that does not indicate it does not support the lifespan protocol. ## License MIT florimondmanca-asgi-lifespan-fcb318f/ci/000077500000000000000000000000001441062231300203105ustar00rootroot00000000000000florimondmanca-asgi-lifespan-fcb318f/ci/azure-pipelines.yml000066400000000000000000000016171441062231300241540ustar00rootroot00000000000000resources: repositories: - repository: templates type: github endpoint: github name: florimondmanca/azure-pipelines-templates ref: refs/tags/6.0 trigger: - master - refs/tags/* pr: - master variables: - name: CI value: "true" - name: PIP_CACHE_DIR value: $(Pipeline.Workspace)/.cache/pip - group: pypi-credentials stages: - stage: test jobs: - template: job--python-check.yml@templates parameters: pythonVersion: "3.11" - template: job--python-test.yml@templates parameters: jobs: py37: py38: py311: coverage: true - stage: publish condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/') jobs: - template: job--python-publish.yml@templates parameters: token: $(pypiToken) pythonVersion: "3.11" florimondmanca-asgi-lifespan-fcb318f/pyproject.toml000066400000000000000000000021201441062231300226240ustar00rootroot00000000000000[build-system] requires = ["setuptools", "setuptools-scm", "wheel"] build-backend = "setuptools.build_meta" [project] name = "asgi-lifespan" description = "Programmatic startup/shutdown of ASGI apps." requires-python = ">=3.7" license = { text = "MIT" } authors = [ { name = "Florimond Manca", email = "florimond.manca@protonmail.com" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Framework :: AsyncIO", "Framework :: Trio", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] dependencies = [ "sniffio", ] dynamic = ["version", "readme"] [project.urls] "Homepage" = "https://github.com/florimondmanca/asgi-lifespan" [tool.setuptools.dynamic] version = { attr = "asgi_lifespan.__version__" } readme = { file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown" } florimondmanca-asgi-lifespan-fcb318f/requirements.txt000066400000000000000000000004741441062231300232060ustar00rootroot00000000000000-e . # Packaging twine wheel # Tooling and tests attrs>=19.2 # Fix compatibility between trio and outcome autoflake black==22.10.* exceptiongroup; python_version<'3.11' flake8==5.* isort==5.* mypy==0.990 pytest==7.* pytest-asyncio==0.18.* pytest-cov pytest-trio==0.8.* starlette==0.26.* trio==0.22.* httpx==0.23.*florimondmanca-asgi-lifespan-fcb318f/setup.cfg000066400000000000000000000004341441062231300215370ustar00rootroot00000000000000[flake8] ignore = W503, E203, B305 max-line-length = 88 [mypy] disallow_untyped_defs = True ignore_missing_imports = True [tool:isort] profile = black [tool:pytest] asyncio_mode = strict addopts = -rxXs --cov=src --cov=tests --cov-report=term-missing --cov-fail-under=90 florimondmanca-asgi-lifespan-fcb318f/setup.py000066400000000000000000000000741441062231300214300ustar00rootroot00000000000000from setuptools import setup setup() # Editable installs. florimondmanca-asgi-lifespan-fcb318f/src/000077500000000000000000000000001441062231300205045ustar00rootroot00000000000000florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/000077500000000000000000000000001441062231300233105ustar00rootroot00000000000000florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/__init__.py000066400000000000000000000003001441062231300254120ustar00rootroot00000000000000from ._exceptions import LifespanNotSupported from ._manager import LifespanManager __version__ = "2.1.0" __all__ = [ "__version__", "LifespanManager", "LifespanNotSupported", ] florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_compat.py000066400000000000000000000002441441062231300253040ustar00rootroot00000000000000try: from contextlib import AsyncExitStack except ImportError: # pragma: no cover from async_exit_stack import AsyncExitStack # type: ignore # noqa: F401 florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_concurrency/000077500000000000000000000000001441062231300260015ustar00rootroot00000000000000florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_concurrency/__init__.py000066400000000000000000000007221441062231300301130ustar00rootroot00000000000000import sniffio from .base import ConcurrencyBackend def detect_concurrency_backend() -> ConcurrencyBackend: library = sniffio.current_async_library() if library == "asyncio": from .asyncio import AsyncioBackend return AsyncioBackend() elif library == "trio": from .trio import TrioBackend return TrioBackend() raise NotImplementedError( f"Unsupported async library: {library}" ) # pragma: no cover florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_concurrency/asyncio.py000066400000000000000000000046051441062231300300250ustar00rootroot00000000000000import asyncio import contextlib import types import typing from .base import BaseEvent, BaseQueue, ConcurrencyBackend class AsyncioEvent(BaseEvent): def __init__(self) -> None: self._event = asyncio.Event() def set(self) -> None: self._event.set() async def wait(self) -> None: await self._event.wait() class AsyncioQueue(BaseQueue): def __init__(self, capacity: int) -> None: self._queue: asyncio.Queue[typing.Any] = asyncio.Queue(maxsize=capacity) async def get(self) -> typing.Any: return await self._queue.get() async def put(self, value: typing.Any) -> None: await self._queue.put(value) class AsyncioBackend(ConcurrencyBackend): def create_event(self) -> BaseEvent: return AsyncioEvent() def create_queue(self, capacity: int) -> BaseQueue: return AsyncioQueue(capacity=capacity) async def run_and_fail_after( self, seconds: typing.Optional[float], coroutine: typing.Callable[[], typing.Awaitable[None]], ) -> None: try: await asyncio.wait_for(coroutine(), timeout=seconds) except asyncio.TimeoutError: raise TimeoutError def run_in_background( self, coroutine: typing.Callable[[], typing.Awaitable[None]] ) -> typing.AsyncContextManager: return Background(coroutine) class Background: def __init__(self, coroutine: typing.Callable[[], typing.Awaitable[None]]) -> None: self.coroutine = coroutine self.task: typing.Optional[asyncio.Task] = None self._task_exception: typing.Optional[BaseException] = None async def __aenter__(self) -> None: async def run_and_silence_cancelled() -> None: with contextlib.suppress(asyncio.CancelledError): await self.coroutine() loop = asyncio.get_event_loop() self.task = loop.create_task(run_and_silence_cancelled()) async def __aexit__( self, exc_type: typing.Optional[typing.Type[BaseException]] = None, exc_value: typing.Optional[BaseException] = None, traceback: typing.Optional[types.TracebackType] = None, ) -> None: assert self.task is not None _, pending = await asyncio.wait({self.task}, timeout=0) if pending: self.task.cancel() await self.task if exc_type is None: self.task.result() florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_concurrency/base.py000066400000000000000000000020531441062231300272650ustar00rootroot00000000000000import typing class BaseEvent: def set(self) -> None: raise NotImplementedError # pragma: no cover async def wait(self) -> None: raise NotImplementedError # pragma: no cover class BaseQueue: async def get(self) -> typing.Any: raise NotImplementedError # pragma: no cover async def put(self, value: typing.Any) -> None: raise NotImplementedError # pragma: no cover class ConcurrencyBackend: def create_event(self) -> BaseEvent: raise NotImplementedError # pragma: no cover def create_queue(self, capacity: int) -> BaseQueue: raise NotImplementedError # pragma: no cover async def run_and_fail_after( self, seconds: typing.Optional[float], coroutine: typing.Callable[[], typing.Awaitable[None]], ) -> None: raise NotImplementedError # pragma: no cover def run_in_background( self, coroutine: typing.Callable[[], typing.Awaitable[None]] ) -> typing.AsyncContextManager: raise NotImplementedError # pragma: no cover florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_concurrency/trio.py000066400000000000000000000041231441062231300273300ustar00rootroot00000000000000import types import typing import trio from .._compat import AsyncExitStack from .base import BaseEvent, BaseQueue, ConcurrencyBackend class TrioEvent(BaseEvent): def __init__(self) -> None: self._event = trio.Event() def set(self) -> None: self._event.set() async def wait(self) -> None: await self._event.wait() class TrioQueue(BaseQueue): def __init__(self, capacity: int) -> None: self._send_channel, self._receive_channel = trio.open_memory_channel( max_buffer_size=capacity ) async def get(self) -> typing.Any: return await self._receive_channel.receive() async def put(self, value: typing.Any) -> None: await self._send_channel.send(value) class TrioBackend(ConcurrencyBackend): def create_event(self) -> BaseEvent: return TrioEvent() def create_queue(self, capacity: int) -> BaseQueue: return TrioQueue(capacity=capacity) async def run_and_fail_after( self, seconds: typing.Optional[float], coroutine: typing.Callable[[], typing.Awaitable[None]], ) -> None: with trio.move_on_after(seconds if seconds is not None else float("inf")): await coroutine() return raise TimeoutError def run_in_background( self, coroutine: typing.Callable[[], typing.Awaitable[None]] ) -> typing.AsyncContextManager: return Background(coroutine) class Background: def __init__(self, coroutine: typing.Callable[[], typing.Awaitable[None]]) -> None: self.coroutine = coroutine self._exit_stack = AsyncExitStack() async def __aenter__(self) -> None: nursery = await self._exit_stack.enter_async_context(trio.open_nursery()) nursery.start_soon(self.coroutine) async def __aexit__( self, exc_type: typing.Optional[typing.Type[BaseException]] = None, exc_value: typing.Optional[BaseException] = None, traceback: typing.Optional[types.TracebackType] = None, ) -> None: await self._exit_stack.__aexit__(exc_type, exc_value, traceback) florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_exceptions.py000066400000000000000000000000601441062231300261760ustar00rootroot00000000000000class LifespanNotSupported(Exception): pass florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_manager.py000066400000000000000000000104061441062231300254340ustar00rootroot00000000000000import typing from contextlib import AsyncExitStack from types import TracebackType from ._concurrency import detect_concurrency_backend from ._exceptions import LifespanNotSupported from ._types import ASGIApp, Message, Receive, Scope, Send def state_middleware(app: ASGIApp, state: typing.Dict[str, typing.Any]) -> ASGIApp: async def app_with_state(scope: Scope, receive: Receive, send: Send) -> None: scope["state"] = state await app(scope, receive, send) return app_with_state class LifespanManager: def __init__( self, app: ASGIApp, startup_timeout: typing.Optional[float] = 5, shutdown_timeout: typing.Optional[float] = 5, ) -> None: self._state: typing.Dict[str, typing.Any] = {} self.app = state_middleware(app, self._state) self.startup_timeout = startup_timeout self.shutdown_timeout = shutdown_timeout self._concurrency_backend = detect_concurrency_backend() self._startup_complete = self._concurrency_backend.create_event() self._shutdown_complete = self._concurrency_backend.create_event() self._receive_queue = self._concurrency_backend.create_queue(capacity=2) self._receive_called = False self._app_exception: typing.Optional[BaseException] = None self._exit_stack = AsyncExitStack() async def startup(self) -> None: await self._receive_queue.put({"type": "lifespan.startup"}) await self._concurrency_backend.run_and_fail_after( self.startup_timeout, self._startup_complete.wait ) if self._app_exception: # Let the caller deal with the exception. raise self._app_exception async def shutdown(self) -> None: await self._receive_queue.put({"type": "lifespan.shutdown"}) await self._concurrency_backend.run_and_fail_after( self.shutdown_timeout, self._shutdown_complete.wait ) async def receive(self) -> Message: self._receive_called = True return await self._receive_queue.get() async def send(self, message: Message) -> None: if not self._receive_called: raise LifespanNotSupported( "Application called send() before receive(). " "Is it missing `assert scope['type'] == 'http'` or similar?" ) if message["type"] == "lifespan.startup.complete": self._startup_complete.set() elif message["type"] == "lifespan.shutdown.complete": self._shutdown_complete.set() async def run_app(self) -> None: scope: Scope = {"type": "lifespan"} try: await self.app(scope, self.receive, self.send) except BaseException as exc: self._app_exception = exc # We crashed, so don't make '.startup()' and '.shutdown()' # wait unnecessarily (or they'll timeout). self._startup_complete.set() self._shutdown_complete.set() if not self._receive_called: raise LifespanNotSupported( "Application failed before making its first call to 'receive()'. " "We expect this to originate from a statement similar to " "`assert scope['type'] == 'type'`. " "If that is not the case, then this crash is unexpected and " "there is probably more debug output in the cause traceback." ) from exc raise async def __aenter__(self) -> "LifespanManager": await self._exit_stack.__aenter__() await self._exit_stack.enter_async_context( self._concurrency_backend.run_in_background(self.run_app) ) try: await self.startup() return self except BaseException: await self._exit_stack.aclose() raise async def __aexit__( self, exc_type: typing.Optional[typing.Type[BaseException]] = None, exc_value: typing.Optional[BaseException] = None, traceback: typing.Optional[TracebackType] = None, ) -> typing.Optional[bool]: if exc_type is None: self._exit_stack.push_async_callback(self.shutdown) return await self._exit_stack.__aexit__(exc_type, exc_value, traceback) florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/_types.py000066400000000000000000000006141441062231300251660ustar00rootroot00000000000000import typing # ASGI types. # Copied from: https://github.com/encode/starlette/blob/master/starlette/types.py Scope = typing.MutableMapping[str, typing.Any] Message = typing.MutableMapping[str, typing.Any] Receive = typing.Callable[[], typing.Awaitable[Message]] Send = typing.Callable[[Message], typing.Awaitable[None]] ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]] florimondmanca-asgi-lifespan-fcb318f/src/asgi_lifespan/py.typed000066400000000000000000000000001441062231300247750ustar00rootroot00000000000000florimondmanca-asgi-lifespan-fcb318f/tests/000077500000000000000000000000001441062231300210575ustar00rootroot00000000000000florimondmanca-asgi-lifespan-fcb318f/tests/__init__.py000066400000000000000000000000001441062231300231560ustar00rootroot00000000000000florimondmanca-asgi-lifespan-fcb318f/tests/compat.py000066400000000000000000000002151441062231300227120ustar00rootroot00000000000000try: ExceptionGroup = ExceptionGroup except NameError: # pragma: no cover from exceptiongroup import ExceptionGroup # type: ignore florimondmanca-asgi-lifespan-fcb318f/tests/concurrency.py000066400000000000000000000036521441062231300237710ustar00rootroot00000000000000""" This module contains concurrency utilities that are only used in tests, thus not required as part of the ConcurrencyBackend API. """ import asyncio import functools import typing import trio from asgi_lifespan._concurrency.asyncio import AsyncioBackend from asgi_lifespan._concurrency.base import ConcurrencyBackend from asgi_lifespan._concurrency.trio import TrioBackend @functools.singledispatch async def sleep(concurrency_backend: ConcurrencyBackend, seconds: float) -> None: raise NotImplementedError # pragma: no cover @sleep.register(AsyncioBackend) async def _sleep_asyncio( concurrency_backend: ConcurrencyBackend, seconds: float ) -> None: await asyncio.sleep(seconds) @sleep.register(TrioBackend) async def _sleep_trio(concurrency_backend: ConcurrencyBackend, seconds: float) -> None: await trio.sleep(seconds) @functools.singledispatch async def run_and_move_on_after( concurrency_backend: ConcurrencyBackend, seconds: typing.Optional[float], coroutine: typing.Callable[[], typing.Awaitable[None]], ) -> bool: raise NotImplementedError # pragma: no cover @run_and_move_on_after.register(AsyncioBackend) async def _run_and_move_on_after_asyncio( concurrency_backend: ConcurrencyBackend, seconds: typing.Optional[float], coroutine: typing.Callable[[], typing.Awaitable[None]], ) -> bool: try: await asyncio.wait_for(coroutine(), timeout=seconds) except asyncio.TimeoutError: return True else: raise NotImplementedError # pragma: no cover @run_and_move_on_after.register(TrioBackend) async def _run_and_move_on_after_trio( concurrency_backend: ConcurrencyBackend, seconds: typing.Optional[float], coroutine: typing.Callable[[], typing.Awaitable[None]], ) -> bool: with trio.move_on_after(seconds if seconds is not None else float("inf")): await coroutine() raise NotImplementedError # pragma: no cover return True florimondmanca-asgi-lifespan-fcb318f/tests/conftest.py000066400000000000000000000003751441062231300232630ustar00rootroot00000000000000import typing import pytest @pytest.fixture( params=[ pytest.param("asyncio", marks=pytest.mark.asyncio), pytest.param("trio", marks=pytest.mark.trio), ] ) def concurrency(request: typing.Any) -> str: return request.param florimondmanca-asgi-lifespan-fcb318f/tests/test_manager.py000066400000000000000000000175471441062231300241200ustar00rootroot00000000000000import contextlib import typing import httpx as httpx import pytest from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import PlainTextResponse, Response from starlette.routing import Route, Router from asgi_lifespan import LifespanManager, LifespanNotSupported from asgi_lifespan._concurrency import detect_concurrency_backend from asgi_lifespan._types import ASGIApp, Message, Receive, Scope, Send from . import concurrency from .compat import ExceptionGroup class StartupFailed(Exception): pass class BodyFailed(Exception): pass class ShutdownFailed(Exception): pass @pytest.mark.usefixtures("concurrency") @pytest.mark.parametrize("startup_exception", (None, StartupFailed)) @pytest.mark.parametrize("body_exception", (None, BodyFailed)) @pytest.mark.parametrize("shutdown_exception", (None, ShutdownFailed)) async def test_lifespan_manager( concurrency: str, startup_exception: typing.Optional[typing.Type[BaseException]], body_exception: typing.Optional[typing.Type[BaseException]], shutdown_exception: typing.Optional[typing.Type[BaseException]], ) -> None: # Setup failing event handlers. on_startup: list = [] on_shutdown: list = [] if startup_exception is not None: async def startup() -> None: assert startup_exception is not None # Please mypy. raise startup_exception() on_startup.append(startup) if shutdown_exception is not None: async def shutdown() -> None: assert shutdown_exception is not None # Please mypy. raise shutdown_exception() on_shutdown.append(shutdown) router = Router(on_startup=on_startup, on_shutdown=on_shutdown) # Set up spying on exchanged ASGI events. received_lifespan_events: typing.List[str] = [] sent_lifespan_events: typing.List[str] = [] async def app(scope: Scope, receive: Receive, send: Send) -> None: assert scope["type"] == "lifespan" async def _receive() -> Message: message = await receive() received_lifespan_events.append(message["type"]) return message async def _send(message: Message) -> None: sent_lifespan_events.append(message["type"]) await send(message) await router(scope, _receive, _send) with contextlib.ExitStack() as stack: # Set up expected raised exceptions. if startup_exception is not None: stack.enter_context(pytest.raises(startup_exception)) elif body_exception is not None: if shutdown_exception is not None: # Trio now raises the new `ExceptionGroup` in case # of multiple errors. (Before 3.11, this will be the backport.) stack.enter_context( pytest.raises( ExceptionGroup if concurrency == "trio" else shutdown_exception ) ) else: stack.enter_context(pytest.raises(body_exception)) elif shutdown_exception is not None: stack.enter_context(pytest.raises(shutdown_exception)) async with LifespanManager(app): # NOTE: this block should not execute in case of startup exception. assert not startup_exception assert received_lifespan_events == ["lifespan.startup"] assert sent_lifespan_events == ["lifespan.startup.complete"] if body_exception is not None: raise body_exception # Check the log of exchanged ASGI messages in all possible cases. if startup_exception: assert received_lifespan_events == ["lifespan.startup"] assert sent_lifespan_events == ["lifespan.startup.failed"] elif body_exception: assert received_lifespan_events == ["lifespan.startup"] assert sent_lifespan_events == [ "lifespan.startup.complete", "lifespan.shutdown.failed", ] elif shutdown_exception: assert received_lifespan_events == ["lifespan.startup", "lifespan.shutdown"] assert sent_lifespan_events == [ "lifespan.startup.complete", "lifespan.shutdown.failed", ] else: assert received_lifespan_events == ["lifespan.startup", "lifespan.shutdown"] assert sent_lifespan_events == [ "lifespan.startup.complete", "lifespan.shutdown.complete", ] async def slow_startup( scope: dict, receive: typing.Callable, send: typing.Callable ) -> None: concurrency_backend = detect_concurrency_backend() message = await receive() assert message["type"] == "lifespan.startup" await concurrency.sleep(concurrency_backend, 0.05) # ... async def slow_shutdown( scope: dict, receive: typing.Callable, send: typing.Callable ) -> None: concurrency_backend = detect_concurrency_backend() message = await receive() assert message["type"] == "lifespan.startup" await send({"type": "lifespan.startup.complete"}) message = await receive() assert message["type"] == "lifespan.shutdown" await concurrency.sleep(concurrency_backend, 0.05) # ... @pytest.mark.usefixtures("concurrency") @pytest.mark.parametrize("app", [slow_startup, slow_shutdown]) async def test_lifespan_timeout(app: typing.Callable) -> None: with pytest.raises(TimeoutError): async with LifespanManager(app, startup_timeout=0.01, shutdown_timeout=0.01): pass @pytest.mark.usefixtures("concurrency") @pytest.mark.parametrize("app", [slow_startup, slow_shutdown]) async def test_lifespan_no_timeout(app: typing.Callable) -> None: async def main() -> None: async with LifespanManager(app, startup_timeout=None, shutdown_timeout=None): pass concurrency_backend = detect_concurrency_backend() timed_out = await concurrency.run_and_move_on_after(concurrency_backend, 0.02, main) assert timed_out async def http_only( scope: dict, receive: typing.Callable, send: typing.Callable ) -> None: assert scope["type"] == "http" # ... async def http_no_assert( scope: dict, receive: typing.Callable, send: typing.Callable ) -> None: await send( { "type": "http.response.start", "status": 200, "headers": [[b"content-type", b"text/plain"]], } ) # ... async def http_no_assert_before_receive_request( scope: dict, receive: typing.Callable, send: typing.Callable ) -> None: message = await receive() assert message["type"] == "http.request" # ... @pytest.mark.usefixtures("concurrency") @pytest.mark.parametrize( "app", [ http_only, http_no_assert, pytest.param( http_no_assert_before_receive_request, marks=pytest.mark.xfail( reason="No way for us to detect unsupported lifespan in this case.", raises=AssertionError, ), ), ], ) async def test_lifespan_not_supported(app: typing.Callable) -> None: with pytest.raises(LifespanNotSupported): async with LifespanManager(app): pass # pragma: no cover @pytest.mark.usefixtures("concurrency") async def test_lifespan_state_async_cm() -> None: @contextlib.asynccontextmanager async def lifespan(_app: ASGIApp) -> typing.AsyncGenerator: yield {"foo": 1} async def get(request: Request) -> Response: assert request.state.foo == 1 request.state.foo = 2 return PlainTextResponse(f"Hello {request.state.foo}") app = Starlette(lifespan=lifespan, routes=[Route("/get", get)]) async with LifespanManager(app) as manager: async with httpx.AsyncClient( app=manager.app, base_url="http://example.org" ) as client: response = await client.get("/get") assert response.status_code == 200 assert response.text == "Hello 2"