pax_global_header00006660000000000000000000000064145701457540014526gustar00rootroot0000000000000052 comment=858d4ceede5bd20de83ab42c5997b15c58036624 aio-libs-aiohttp-sse-8169864/000077500000000000000000000000001457014575400156765ustar00rootroot00000000000000aio-libs-aiohttp-sse-8169864/.coveragerc000066400000000000000000000002221457014575400200130ustar00rootroot00000000000000# .coveragerc to control coverage.py [run] branch = True [report] exclude_also = ^\s*if TYPE_CHECKING: : \.\.\.(\s*#.*)?$ ^ +\.\.\.$ aio-libs-aiohttp-sse-8169864/.github/000077500000000000000000000000001457014575400172365ustar00rootroot00000000000000aio-libs-aiohttp-sse-8169864/.github/dependabot.yml000066400000000000000000000003121457014575400220620ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" aio-libs-aiohttp-sse-8169864/.github/workflows/000077500000000000000000000000001457014575400212735ustar00rootroot00000000000000aio-libs-aiohttp-sse-8169864/.github/workflows/auto-merge.yml000066400000000000000000000011401457014575400240570ustar00rootroot00000000000000name: Dependabot auto-merge on: pull_request_target permissions: pull-requests: write contents: write jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v1.6.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} aio-libs-aiohttp-sse-8169864/.github/workflows/ci.yml000066400000000000000000000057251457014575400224220ustar00rootroot00000000000000name: CI on: push: branches: - master - '[0-9].[0-9]+' # matches to backport branches, e.g. 3.6 tags: [ 'v*' ] pull_request: branches: - master - '[0-9].[0-9]+' jobs: lint: name: Linter runs-on: ubuntu-latest timeout-minutes: 5 outputs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: 3.11 cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements-dev.txt - name: Run linters run: | make lint test: name: Test strategy: matrix: pyver: ['3.8', '3.9', '3.10', '3.11', '3.12'] os: [ubuntu, macos, windows] runs-on: ${{ matrix.os }}-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Python ${{ matrix.pyver }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.pyver }} cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements.txt - name: Run unittests run: pytest tests env: COLOR: 'yes' - run: python -m coverage xml - name: Upload coverage uses: codecov/codecov-action@v4 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} check: # This job does nothing and is only used for the branch protection if: always() needs: [lint, test] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} deploy: name: Deploy on PyPI needs: check environment: release runs-on: ubuntu-latest # Run only on pushing a tag if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: 3.11 - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements-dev.txt - name: Update build deps run: | pip install -U build twine - name: Build dists run: | python -m build - name: Make Release uses: aio-libs/create-release@v1.6.6 with: changes_file: CHANGES.rst name: aiohttp-sse version_file: aiohttp_sse/__init__.py github_token: ${{ secrets.GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_TOKEN }} dist_dir: dist fix_issue_regex: "`#(\\d+) `_" fix_issue_repl: "#\\1" aio-libs-aiohttp-sse-8169864/.github/workflows/codeql.yml000066400000000000000000000044271457014575400232740ustar00rootroot00000000000000name: "CodeQL" on: push: branches: [ 'master' ] pull_request: # The branches below must be a subset of the branches above branches: [ 'master' ] schedule: - cron: '18 1 * * 4' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" aio-libs-aiohttp-sse-8169864/.gitignore000066400000000000000000000013271457014575400176710ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .cache nosetests.xml coverage.xml # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ # PyCharm .idea coverage/ cover/ .python-version aio-libs-aiohttp-sse-8169864/.mypy.ini000066400000000000000000000016701457014575400174570ustar00rootroot00000000000000[mypy] files = aiohttp_sse, tests, examples check_untyped_defs = True follow_imports_for_stubs = True disallow_any_decorated = True disallow_any_generics = True disallow_any_unimported = True disallow_incomplete_defs = True disallow_subclassing_any = True disallow_untyped_calls = True disallow_untyped_decorators = True disallow_untyped_defs = True # TODO(PY312): explicit-override enable_error_code = ignore-without-code, possibly-undefined, redundant-expr, redundant-self, truthy-bool, truthy-iterable, unused-awaitable extra_checks = True implicit_reexport = False no_implicit_optional = True pretty = True show_column_numbers = True show_error_codes = True show_error_code_links = True strict_equality = True warn_incomplete_stub = True warn_redundant_casts = True warn_return_any = True warn_unreachable = True warn_unused_ignores = True [mypy-tests.*] disallow_any_decorated = False disallow_untyped_calls = False disallow_untyped_defs = False aio-libs-aiohttp-sse-8169864/.pre-commit-config.yaml000066400000000000000000000026511457014575400221630ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v4.5.0' hooks: - id: check-merge-conflict - repo: https://github.com/asottile/yesqa rev: v1.5.0 hooks: - id: yesqa - repo: https://github.com/PyCQA/isort rev: '5.13.2' hooks: - id: isort - repo: https://github.com/psf/black rev: '24.2.0' hooks: - id: black language_version: python3 # Should be a command that runs python3.6+ - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v4.5.0' hooks: - id: end-of-file-fixer - id: requirements-txt-fixer - id: trailing-whitespace - id: file-contents-sorter files: | docs/spelling_wordlist.txt| .gitignore| .gitattributes - id: check-case-conflict - id: check-json - id: check-xml - id: check-executables-have-shebangs - id: check-toml - id: check-xml - id: check-yaml - id: debug-statements - id: check-added-large-files - id: check-symlinks - id: debug-statements - id: detect-aws-credentials args: ['--allow-missing-credentials'] - id: detect-private-key exclude: ^examples/ - repo: https://github.com/asottile/pyupgrade rev: 'v3.15.1' hooks: - id: pyupgrade args: ['--py38-plus'] - repo: https://github.com/PyCQA/flake8 rev: '7.0.0' hooks: - id: flake8 exclude: "^docs/" - repo: https://github.com/Lucas-C/pre-commit-hooks-markup rev: v1.0.1 hooks: - id: rst-linter files: >- ^[^/]+[.]rst$ aio-libs-aiohttp-sse-8169864/CHANGES.rst000066400000000000000000000030451457014575400175020ustar00rootroot00000000000000======= CHANGES ======= .. towncrier release notes start 2.2.0 (2024-02-29) ================== - Added typing support. - Added ``EventSourceResponse.is_connected()`` method. - Added ``EventSourceResponse.last_event_id()`` method. - Added support for SSE with HTTP methods other than GET. - Added support for float ping intervals. - Fixed (on Python 3.11+) ``EventSourceResponse.wait()`` swallowing user cancellation. - Fixed ping task not getting cancelled after a send failure. - Cancelled the ping task when a connection error occurs to help avoid errors. - Dropped support for Python 3.7 while adding support upto Python 3.12. 2.1.0 (2021-11-13) ================== Features -------- - Added Python 3.10 support (`#314 `_) Deprecations and Removals ------------------------- - Drop Python 3.6 support (`#319 `_) Misc ---- - `#163 `_ 2.0.0 (2018-02-19) ================== - Drop aiohttp < 3 support - ``EventSourceResponse.send`` is now a coroutine. 1.1.0 (2017-08-21) ================== - Drop python 3.4 support - Add new context manager API 1.0.0 (2017-04-14) ================== - Release aiohttp-sse==1.0.0 0.1.0 (2017-03-23) ================== - add support for asynchronous context manager interface - tests refactoring - modernize internal api to align with aiohttp 0.0.2 (2017-01-13) ================== - Added MANIFEST.in 0.0.1 (2017-01-13) ================== - Initial release aio-libs-aiohttp-sse-8169864/CONTRIBUTING.rst000066400000000000000000000025401457014575400203400ustar00rootroot00000000000000Contributing ============ Running Tests ------------- .. _GitHub: https://github.com/aio-libs/aiohttp-sse Thanks for your interest in contributing to ``aiohttp-sse``, there are multiple ways and places you can contribute. Fist of all just clone repository:: $ git clone git@github.com:aio-libs/aiohttp-sse.git Create virtualenv with python. For example using *virtualenvwrapper* commands could look like:: $ cd aiohttp-sse $ mkvirtualenv --python=`which python3.12` aiohttp-sse After that please install libraries required for development:: $ pip install -r requirements-dev.txt $ pip install -e . Congratulations, you are ready to run the test suite:: $ make cov To run individual test use following command:: $ py.test -sv tests/test_sse.py -k test_name Reporting an Issue ------------------ If you have found issue with `aiohttp-sse` please do not hesitate to file an issue on the GitHub_ project. When filing your issue please make sure you can express the issue with a reproducible test case. When reporting an issue we also need as much information about your environment that you can include. We never know what information will be pertinent when trying narrow down the issue. Please include at least the following information: * Versions of `aiohttp-sse`, `aiohttp` and `python`. * Platform you're running on (OS X, Linux). aio-libs-aiohttp-sse-8169864/LICENSE000066400000000000000000000010701457014575400167010ustar00rootroot00000000000000Copyright 2015-2020 aio-libs collaboration. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. aio-libs-aiohttp-sse-8169864/MANIFEST.in000066400000000000000000000001441457014575400174330ustar00rootroot00000000000000include LICENSE include CHANGES.txt include README.rst graft aiohttp_sse global-exclude *.pyc *.swp aio-libs-aiohttp-sse-8169864/Makefile000066400000000000000000000016061457014575400173410ustar00rootroot00000000000000# Some simple testing tasks (sorry, UNIX only). setup: pip install -r requirements-dev.txt pre-commit install .PHONY: fmt fmt: python -m pre_commit run --all-files --show-diff-on-failure .PHONY: lint lint: fmt mypy test: pytest -sv tests/ cov cover coverage: pytest -sv tests/ --cov=aiohttp_sse --cov-report=html @echo "open file://`pwd`/htmlcov/index.html" clean: rm -rf `find . -name __pycache__` rm -f `find . -type f -name '*.py[co]' ` rm -f `find . -type f -name '*~' ` rm -f `find . -type f -name '.*~' ` rm -f `find . -type f -name '@*' ` rm -f `find . -type f -name '#*#' ` rm -f `find . -type f -name '*.orig' ` rm -f `find . -type f -name '*.rej' ` rm -f .coverage rm -rf coverage rm -rf build rm -rf cover rm -rf htmlcov doc: make -C docs html @echo "open file://`pwd`/docs/_build/html/index.html" .PHONY: all build venv flake test vtest testloop cov clean doc aio-libs-aiohttp-sse-8169864/README.rst000066400000000000000000000054161457014575400173730ustar00rootroot00000000000000aiohttp-sse =========== .. image:: https://github.com/aio-libs/aiohttp-sse/workflows/CI/badge.svg?event=push :target: https://github.com/aio-libs/aiohttp-sse/actions?query=event%3Apush+branch%3Amaster .. image:: https://codecov.io/gh/aio-libs/aiohttp-sse/branch/master/graph/badge.svg :target: https://codecov.io/gh/aio-libs/aiohttp-sse .. image:: https://pyup.io/repos/github/aio-libs/aiohttp-sse/shield.svg :target: https://pyup.io/repos/github/aio-libs/aiohttp-sse/ :alt: Updates .. image:: https://badges.gitter.im/Join%20Chat.svg :target: https://gitter.im/aio-libs/Lobby :alt: Chat on Gitter The **EventSource** interface is used to receive server-sent events. It connects to a server over HTTP and receives events in text/event-stream format without closing the connection. *aiohttp-sse* provides support for server-sent events for aiohttp_. Installation ------------ Installation process as simple as:: $ pip install aiohttp-sse Example ------- .. code:: python import asyncio from datetime import datetime from aiohttp import web from aiohttp_sse import sse_response async def hello(request: web.Request) -> web.StreamResponse: async with sse_response(request) as resp: while resp.is_connected(): time_dict = {"time": f"Server Time : {datetime.now()}"} data = json.dumps(time_dict, indent=2) print(data) await resp.send(data) await asyncio.sleep(1) return resp async def index(_request: web.Request) -> web.StreamResponse: html = """

Response from server:

""" return web.Response(text=html, content_type="text/html") app = web.Application() app.router.add_route("GET", "/hello", hello) app.router.add_route("GET", "/", index) web.run_app(app, host="127.0.0.1", port=8080) EventSource Protocol -------------------- * http://www.w3.org/TR/2011/WD-eventsource-20110310/ * https://developer.mozilla.org/en-US/docs/Server-sent_events/Using_server-sent_events Requirements ------------ * aiohttp_ 3+ License ------- The *aiohttp-sse* is offered under Apache 2.0 license. .. _Python: https://www.python.org .. _asyncio: http://docs.python.org/3/library/asyncio.html .. _aiohttp: https://github.com/aio-libs/aiohttp aio-libs-aiohttp-sse-8169864/aiohttp_sse/000077500000000000000000000000001457014575400202205ustar00rootroot00000000000000aio-libs-aiohttp-sse-8169864/aiohttp_sse/__init__.py000066400000000000000000000214571457014575400223420ustar00rootroot00000000000000import asyncio import io import re import sys from types import TracebackType from typing import Any, Mapping, Optional, Type, TypeVar, Union, overload from aiohttp.abc import AbstractStreamWriter from aiohttp.web import BaseRequest, ContentCoding, Request, StreamResponse from .helpers import _ContextManager __version__ = "2.2.0" __all__ = ["EventSourceResponse", "sse_response"] class EventSourceResponse(StreamResponse): """This object could be used as regular aiohttp response for streaming data to client, usually browser with EventSource:: async def hello(request): # create response object resp = await EventSourceResponse() async with resp: # stream data resp.send('foo') return resp """ DEFAULT_PING_INTERVAL = 15 DEFAULT_SEPARATOR = "\r\n" DEFAULT_LAST_EVENT_HEADER = "Last-Event-Id" LINE_SEP_EXPR = re.compile(r"\r\n|\r|\n") def __init__( self, *, status: int = 200, reason: Optional[str] = None, headers: Optional[Mapping[str, str]] = None, sep: Optional[str] = None, ): super().__init__(status=status, reason=reason) if headers is not None: self.headers.extend(headers) # mandatory for servers-sent events headers self.headers["Content-Type"] = "text/event-stream" self.headers["Cache-Control"] = "no-cache" self.headers["Connection"] = "keep-alive" self.headers["X-Accel-Buffering"] = "no" self._ping_interval: float = self.DEFAULT_PING_INTERVAL self._ping_task: Optional[asyncio.Task[None]] = None self._sep = sep if sep is not None else self.DEFAULT_SEPARATOR def is_connected(self) -> bool: """Check connection is prepared and ping task is not done.""" if not self.prepared or self._ping_task is None: return False return not self._ping_task.done() async def _prepare(self, request: Request) -> "EventSourceResponse": # TODO(PY311): Use Self for return type. await self.prepare(request) return self async def prepare(self, request: BaseRequest) -> Optional[AbstractStreamWriter]: """Prepare for streaming and send HTTP headers. :param request: regular aiohttp.web.Request. """ if not self.prepared: writer = await super().prepare(request) self._ping_task = asyncio.create_task(self._ping()) # explicitly enabling chunked encoding, since content length # usually not known beforehand. self.enable_chunked_encoding() return writer else: # hackish way to check if connection alive # should be updated once we have proper API in aiohttp # https://github.com/aio-libs/aiohttp/issues/3105 if request.protocol.transport is None: # request disconnected raise asyncio.CancelledError() return self._payload_writer async def send( self, data: str, id: Optional[str] = None, event: Optional[str] = None, retry: Optional[int] = None, ) -> None: """Send data using EventSource protocol :param str data: The data field for the message. :param str id: The event ID to set the EventSource object's last event ID value to. :param str event: The event's type. If this is specified, an event will be dispatched on the browser to the listener for the specified event name; the web site would use addEventListener() to listen for named events. The default event type is "message". :param int retry: The reconnection time to use when attempting to send the event. [What code handles this?] This must be an integer, specifying the reconnection time in milliseconds. If a non-integer value is specified, the field is ignored. """ buffer = io.StringIO() if id is not None: buffer.write(self.LINE_SEP_EXPR.sub("", f"id: {id}")) buffer.write(self._sep) if event is not None: buffer.write(self.LINE_SEP_EXPR.sub("", f"event: {event}")) buffer.write(self._sep) for chunk in self.LINE_SEP_EXPR.split(data): buffer.write(f"data: {chunk}") buffer.write(self._sep) if retry is not None: if not isinstance(retry, int): raise TypeError("retry argument must be int") buffer.write(f"retry: {retry}") buffer.write(self._sep) buffer.write(self._sep) try: await self.write(buffer.getvalue().encode("utf-8")) except ConnectionResetError: self.stop_streaming() raise async def wait(self) -> None: """EventSourceResponse object is used for streaming data to the client, this method returns future, so we can wait until connection will be closed or other task explicitly call ``stop_streaming`` method. """ if self._ping_task is None: raise RuntimeError("Response is not started") try: await self._ping_task except asyncio.CancelledError: if ( sys.version_info >= (3, 11) and (task := asyncio.current_task()) and task.cancelling() ): raise def stop_streaming(self) -> None: """Used in conjunction with ``wait`` could be called from other task to notify client that server no longer wants to stream anything. """ if self._ping_task is None: raise RuntimeError("Response is not started") self._ping_task.cancel() def enable_compression( self, force: Union[bool, ContentCoding, None] = False ) -> None: raise NotImplementedError @property def last_event_id(self) -> Optional[str]: """Last event ID, requested by client.""" if self._req is None: msg = "EventSource request must be prepared first" raise RuntimeError(msg) return self._req.headers.get(self.DEFAULT_LAST_EVENT_HEADER) @property def ping_interval(self) -> float: """Time interval between two ping massages""" return self._ping_interval @ping_interval.setter def ping_interval(self, value: float) -> None: """Setter for ping_interval property. :param value: interval in sec between two ping values. """ if not isinstance(value, (int, float)): raise TypeError("ping interval must be int or float") if value < 0: raise ValueError("ping interval must be greater then 0") self._ping_interval = value async def _ping(self) -> None: # periodically send ping to the browser. Any message that # starts with ":" colon ignored by a browser and could be used # as ping message. message = ": ping{0}{0}".format(self._sep).encode("utf-8") while True: await asyncio.sleep(self._ping_interval) try: await self.write(message) except (ConnectionResetError, RuntimeError): # RuntimeError - on writing after EOF break async def __aenter__(self) -> "EventSourceResponse": # TODO(PY311): Use Self return self async def __aexit__( self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: self.stop_streaming() await self.wait() # TODO(PY313): Use default and remove overloads. ESR = TypeVar("ESR", bound=EventSourceResponse) @overload def sse_response( request: Request, *, status: int = 200, reason: Optional[str] = None, headers: Optional[Mapping[str, str]] = None, sep: Optional[str] = None, ) -> _ContextManager[EventSourceResponse]: ... @overload def sse_response( request: Request, *, status: int = 200, reason: Optional[str] = None, headers: Optional[Mapping[str, str]] = None, sep: Optional[str] = None, response_cls: Type[ESR], ) -> _ContextManager[ESR]: ... def sse_response( request: Request, *, status: int = 200, reason: Optional[str] = None, headers: Optional[Mapping[str, str]] = None, sep: Optional[str] = None, response_cls: Type[EventSourceResponse] = EventSourceResponse, ) -> Any: if not issubclass(response_cls, EventSourceResponse): raise TypeError( "response_cls must be subclass of " "aiohttp_sse.EventSourceResponse, got {}".format(response_cls) ) sse = response_cls(status=status, reason=reason, headers=headers, sep=sep) return _ContextManager(sse._prepare(request)) aio-libs-aiohttp-sse-8169864/aiohttp_sse/helpers.py000066400000000000000000000034621457014575400222410ustar00rootroot00000000000000from types import TracebackType from typing import ( Any, AsyncContextManager, Coroutine, Generator, Optional, Type, TypeVar, ) T = TypeVar("T", bound=AsyncContextManager["T"]) # type: ignore[misc] class _ContextManager(Coroutine[T, None, T]): __slots__ = ("_coro", "_obj") def __init__(self, coro: Coroutine[T, None, T]) -> None: self._coro = coro self._obj: Optional[T] = None def send(self, arg: Any) -> T: return self._coro.send(arg) # pragma: no cover def throw(self, *args) -> T: # type: ignore[no-untyped-def] return self._coro.throw(*args) # pragma: no cover def close(self) -> None: return self._coro.close() # pragma: no cover @property def gi_frame(self) -> Any: # type: ignore[misc] return self._coro.gi_frame # type: ignore[attr-defined] # pragma: no cover @property def gi_running(self) -> Any: # type: ignore[misc] return self._coro.gi_running # type: ignore[attr-defined] # pragma: no cover @property def gi_code(self) -> Any: # type: ignore[misc] return self._coro.gi_code # type: ignore[attr-defined] # pragma: no cover def __next__(self) -> T: return self.send(None) # pragma: no cover def __await__(self) -> Generator[T, None, T]: return self._coro.__await__() async def __aenter__(self) -> T: self._obj = await self._coro return await self._obj.__aenter__() # type: ignore[no-any-return] async def __aexit__( self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType], ) -> Optional[bool]: if self._obj is None: # pragma: no cover return False return await self._obj.__aexit__(exc_type, exc, tb) aio-libs-aiohttp-sse-8169864/aiohttp_sse/py.typed000066400000000000000000000000001457014575400217050ustar00rootroot00000000000000aio-libs-aiohttp-sse-8169864/examples/000077500000000000000000000000001457014575400175145ustar00rootroot00000000000000aio-libs-aiohttp-sse-8169864/examples/chat.py000066400000000000000000000063271457014575400210150ustar00rootroot00000000000000import asyncio import json from typing import Set from aiohttp import web from aiohttp_sse import EventSourceResponse, sse_response channels = web.AppKey("channels", Set[asyncio.Queue[str]]) async def chat(_request: web.Request) -> web.Response: html = """ Tiny Chat
Anonymous :
""" return web.Response(text=html, content_type="text/html") async def message(request: web.Request) -> web.Response: app = request.app data = await request.post() for queue in app[channels]: payload = json.dumps(dict(data)) await queue.put(payload) return web.Response() async def subscribe(request: web.Request) -> EventSourceResponse: async with sse_response(request) as response: app = request.app queue: asyncio.Queue[str] = asyncio.Queue() print("Someone joined.") app[channels].add(queue) try: while response.is_connected(): payload = await queue.get() await response.send(payload) queue.task_done() finally: app[channels].remove(queue) print("Someone left.") return response if __name__ == "__main__": app = web.Application() app[channels] = set() # type: ignore[misc] app.router.add_route("GET", "/", chat) app.router.add_route("POST", "/everyone", message) app.router.add_route("GET", "/subscribe", subscribe) web.run_app(app, host="127.0.0.1", port=8080) aio-libs-aiohttp-sse-8169864/examples/graceful_shutdown.py000066400000000000000000000064711457014575400236210ustar00rootroot00000000000000import asyncio import json import logging import weakref from contextlib import suppress from datetime import datetime from functools import partial from typing import Any, Callable, Dict, Optional from aiohttp import web from aiohttp_sse import EventSourceResponse, sse_response streams_key = web.AppKey("streams_key", weakref.WeakSet["SSEResponse"]) worker_key = web.AppKey("worker_key", asyncio.Task[None]) class SSEResponse(EventSourceResponse): async def send_json( self, data: Dict[str, Any], id: Optional[str] = None, event: Optional[str] = None, retry: Optional[int] = None, json_dumps: Callable[[Any], str] = partial(json.dumps, indent=2), ) -> None: await self.send(json_dumps(data), id=id, event=event, retry=retry) async def send_event( stream: SSEResponse, data: Dict[str, Any], event_id: str, ) -> None: try: await stream.send_json(data, id=event_id) except Exception: logging.exception("Exception when sending event: %s", event_id) async def worker(app: web.Application) -> None: while True: now = datetime.now() delay = asyncio.create_task(asyncio.sleep(1)) # Fire fs = [] for stream in app[streams_key]: data = { "time": f"Server Time : {now}", "last_event_id": stream.last_event_id, } coro = send_event(stream, data, str(now.timestamp())) fs.append(coro) # Run in parallel await asyncio.gather(*fs) # Sleep 1s - n await delay async def on_startup(app: web.Application) -> None: app[streams_key] = weakref.WeakSet[SSEResponse]() app[worker_key] = asyncio.create_task(worker(app)) async def clean_up(app: web.Application) -> None: app[worker_key].cancel() with suppress(asyncio.CancelledError): await app[worker_key] async def on_shutdown(app: web.Application) -> None: waiters = [] for stream in app[streams_key]: stream.stop_streaming() waiters.append(stream.wait()) await asyncio.gather(*waiters, return_exceptions=True) app[streams_key].clear() async def hello(request: web.Request) -> web.StreamResponse: stream: SSEResponse = await sse_response(request, response_cls=SSEResponse) request.app[streams_key].add(stream) try: await stream.wait() finally: request.app[streams_key].discard(stream) return stream async def index(_request: web.Request) -> web.StreamResponse: d = """

Response from server:


        
    
    """
    return web.Response(text=d, content_type="text/html")


if __name__ == "__main__":
    app = web.Application()

    app.on_startup.append(on_startup)
    app.on_shutdown.append(on_shutdown)
    app.on_cleanup.append(clean_up)

    app.router.add_route("GET", "/hello", hello)
    app.router.add_route("GET", "/", index)
    web.run_app(app, host="127.0.0.1", port=8080)
aio-libs-aiohttp-sse-8169864/examples/simple.py000066400000000000000000000023001457014575400213520ustar00rootroot00000000000000import asyncio
from datetime import datetime

from aiohttp import web

from aiohttp_sse import sse_response


async def hello(request: web.Request) -> web.StreamResponse:
    async with sse_response(request) as resp:
        while resp.is_connected():
            data = f"Server Time : {datetime.now()}"
            print(data)
            await resp.send(data)
            await asyncio.sleep(1)
    return resp


async def index(_request: web.Request) -> web.StreamResponse:
    html = """
        
            
                
                

Response from server:

""" return web.Response(text=html, content_type="text/html") if __name__ == "__main__": app = web.Application() app.router.add_route("GET", "/hello", hello) app.router.add_route("GET", "/", index) web.run_app(app, host="127.0.0.1", port=8080) aio-libs-aiohttp-sse-8169864/pyproject.toml000066400000000000000000000006331457014575400206140ustar00rootroot00000000000000[build-system] requires = ["setuptools>=51", "wheel>=0.36"] build-backend = "setuptools.build_meta" [tool.black] exclude = ''' /( \.git | venv | __pycache__ | \.tox )/ ''' [tool.towncrier] package = "aiohttp_sse" filename = "CHANGES.rst" directory = "CHANGES/" title_format = "{version} ({project_date})" issue_format = "`#{issue} `_" aio-libs-aiohttp-sse-8169864/pytest.ini000066400000000000000000000006101457014575400177240ustar00rootroot00000000000000[pytest] addopts = # show 10 slowest invocations: --durations=10 # a bit of verbosity doesn't hurt: -v # report all the things == -rxXs: -ra # show values of the local vars in errors: --showlocals # coverage reports --cov=aiohttp_sse/ --cov=tests/ --cov-report term asyncio_mode = auto filterwarnings = error testpaths = tests/ xfail_strict = true aio-libs-aiohttp-sse-8169864/requirements-dev.txt000066400000000000000000000000631457014575400217350ustar00rootroot00000000000000-r requirements.txt mypy==1.8.0 pre-commit==3.6.2 aio-libs-aiohttp-sse-8169864/requirements.txt000066400000000000000000000001411457014575400211560ustar00rootroot00000000000000-e . aiohttp==3.9.3 pytest==8.0.2 pytest-aiohttp==1.0.5 pytest-asyncio==0.23.5 pytest-cov==4.1.0 aio-libs-aiohttp-sse-8169864/setup.cfg000066400000000000000000000002741457014575400175220ustar00rootroot00000000000000[flake8] # TODO: don't disable D*, fix up issues instead ignore = N801,N802,N803,E203,E226,E305,W504,E252,E301,E302,E704,W503,W504,F811,D1,D4 max-line-length = 88 [isort] profile = black aio-libs-aiohttp-sse-8169864/setup.py000066400000000000000000000037271457014575400174210ustar00rootroot00000000000000import ast import codecs import os import sys from setuptools import find_packages, setup PY_VER = sys.version_info if not PY_VER >= (3, 8): raise RuntimeError("aiohttp-sse doesn't support Python earlier than 3.8") def read(f): with codecs.open( os.path.join(os.path.dirname(__file__), f), encoding="utf-8" ) as ofile: return ofile.read() class VersionFinder(ast.NodeVisitor): def __init__(self): self.version = None def visit_Assign(self, node): if not self.version: if node.targets[0].id == "__version__": self.version = node.value.s def read_version(): init_py = os.path.join(os.path.dirname(__file__), "aiohttp_sse", "__init__.py") finder = VersionFinder() finder.visit(ast.parse(read(init_py))) if finder.version is None: msg = "Cannot find version in aiohttp_sse/__init__.py" raise RuntimeError(msg) return finder.version install_requires = ["aiohttp>=3.0"] setup( name="aiohttp-sse", version=read_version(), description=("Server-sent events support for aiohttp."), long_description=read("README.rst"), classifiers=[ "License :: OSI Approved :: Apache Software License", "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Internet :: WWW/HTTP", "Framework :: AsyncIO", "Framework :: aiohttp", ], author="Nikolay Novik", author_email="nickolainovik@gmail.com", url="https://github.com/aio-libs/aiohttp_sse/", license="Apache 2", python_requires=">=3.8", packages=find_packages(), install_requires=install_requires, include_package_data=True, ) aio-libs-aiohttp-sse-8169864/tests/000077500000000000000000000000001457014575400170405ustar00rootroot00000000000000aio-libs-aiohttp-sse-8169864/tests/conftest.py000066400000000000000000000006611457014575400212420ustar00rootroot00000000000000from asyncio import AbstractEventLoop from typing import cast import pytest @pytest.fixture( scope="session", params=[True, False], ids=["debug:true", "debug:false"], ) def debug(request: pytest.FixtureRequest) -> bool: return cast(bool, request.param) @pytest.fixture(autouse=True) def loop(event_loop: AbstractEventLoop, debug: bool) -> AbstractEventLoop: event_loop.set_debug(debug) return event_loop aio-libs-aiohttp-sse-8169864/tests/test_sse.py000066400000000000000000000415621457014575400212530ustar00rootroot00000000000000import asyncio import sys from typing import Awaitable, Callable, List import pytest from aiohttp import web from aiohttp.test_utils import TestClient, make_mocked_request from aiohttp_sse import EventSourceResponse, sse_response ClientFixture = Callable[[web.Application], Awaitable[TestClient]] socket = web.AppKey("socket", List[EventSourceResponse]) @pytest.mark.parametrize( "with_sse_response", (False, True), ids=("without_sse_response", "with_sse_response"), ) async def test_func(with_sse_response: bool, aiohttp_client: ClientFixture) -> None: async def func(request: web.Request) -> web.StreamResponse: if with_sse_response: resp = await sse_response(request, headers={"X-SSE": "aiohttp_sse"}) else: resp = EventSourceResponse(headers={"X-SSE": "aiohttp_sse"}) await resp.prepare(request) await resp.send("foo") await resp.send("foo", event="bar") await resp.send("foo", event="bar", id="xyz") await resp.send("foo", event="bar", id="xyz", retry=1) resp.stop_streaming() await resp.wait() return resp app = web.Application() app.router.add_route("GET", "/", func) app.router.add_route("POST", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert 200 == resp.status # make sure that EventSourceResponse supports passing # custom headers assert resp.headers.get("X-SSE") == "aiohttp_sse" # make sure default headers set assert resp.headers.get("Content-Type") == "text/event-stream" assert resp.headers.get("Cache-Control") == "no-cache" assert resp.headers.get("Connection") == "keep-alive" assert resp.headers.get("X-Accel-Buffering") == "no" # check streamed data streamed_data = await resp.text() expected = ( "data: foo\r\n\r\n" "event: bar\r\ndata: foo\r\n\r\n" "id: xyz\r\nevent: bar\r\ndata: foo\r\n\r\n" "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" ) assert streamed_data == expected async def test_wait_stop_streaming(aiohttp_client: ClientFixture) -> None: async def func(request: web.Request) -> web.StreamResponse: app = request.app resp = EventSourceResponse() await resp.prepare(request) await resp.send("foo", event="bar", id="xyz", retry=1) app[socket].append(resp) await resp.wait() return resp app = web.Application() app[socket] = [] # type: ignore[misc] app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp_task = asyncio.create_task(client.get("/")) await asyncio.sleep(0.1) esourse = app[socket][0] esourse.stop_streaming() await esourse.wait() resp = await resp_task assert 200 == resp.status streamed_data = await resp.text() expected = "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" assert streamed_data == expected async def test_retry(aiohttp_client: ClientFixture) -> None: async def func(request: web.Request) -> web.StreamResponse: resp = EventSourceResponse() await resp.prepare(request) with pytest.raises(TypeError): await resp.send("foo", retry="one") # type: ignore[arg-type] await resp.send("foo", retry=1) resp.stop_streaming() await resp.wait() return resp app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert 200 == resp.status # check streamed data streamed_data = await resp.text() expected = "data: foo\r\nretry: 1\r\n\r\n" assert streamed_data == expected async def test_wait_stop_streaming_errors() -> None: response = EventSourceResponse() with pytest.raises(RuntimeError) as ctx: await response.wait() assert str(ctx.value) == "Response is not started" with pytest.raises(RuntimeError) as ctx: response.stop_streaming() assert str(ctx.value) == "Response is not started" def test_compression_not_implemented() -> None: response = EventSourceResponse() with pytest.raises(NotImplementedError): response.enable_compression() class TestPingProperty: @pytest.mark.parametrize("value", (25, 25.0, 0), ids=("int", "float", "zero int")) def test_success(self, value: float) -> None: response = EventSourceResponse() response.ping_interval = value assert response.ping_interval == value @pytest.mark.parametrize("value", [None, "foo"], ids=("None", "str")) def test_wrong_type(self, value: float) -> None: response = EventSourceResponse() with pytest.raises(TypeError) as ctx: response.ping_interval = value assert ctx.match("ping interval must be int or float") def test_negative_int(self) -> None: response = EventSourceResponse() with pytest.raises(ValueError) as ctx: response.ping_interval = -42 assert ctx.match("ping interval must be greater then 0") def test_default_value(self) -> None: response = EventSourceResponse() assert response.ping_interval == response.DEFAULT_PING_INTERVAL async def test_ping(aiohttp_client: ClientFixture) -> None: async def func(request: web.Request) -> web.StreamResponse: app = request.app resp = EventSourceResponse() resp.ping_interval = 1 await resp.prepare(request) await resp.send("foo") app[socket].append(resp) await resp.wait() return resp app = web.Application() app[socket] = [] # type: ignore[misc] app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp_task = asyncio.create_task(client.get("/")) await asyncio.sleep(1.15) esourse = app[socket][0] esourse.stop_streaming() await esourse.wait() resp = await resp_task assert 200 == resp.status streamed_data = await resp.text() expected = "data: foo\r\n\r\n" + ": ping\r\n\r\n" assert streamed_data == expected async def test_ping_reset( aiohttp_client: ClientFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: async def func(request: web.Request) -> web.StreamResponse: app = request.app resp = EventSourceResponse() resp.ping_interval = 1 await resp.prepare(request) await resp.send("foo") app[socket].append(resp) await resp.wait() return resp app = web.Application() app[socket] = [] # type: ignore[misc] app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp_task = asyncio.create_task(client.get("/")) await asyncio.sleep(1.15) esource = app[socket][0] def reset_error_write(data: str) -> None: raise ConnectionResetError("Cannot write to closing transport") assert esource._ping_task assert not esource._ping_task.done() monkeypatch.setattr(esource, "write", reset_error_write) await esource.wait() assert esource._ping_task.done() resp = await resp_task assert 200 == resp.status streamed_data = await resp.text() expected = "data: foo\r\n\r\n" + ": ping\r\n\r\n" assert streamed_data == expected async def test_ping_auto_close(aiohttp_client: ClientFixture) -> None: """Test ping task automatically closed on send failure.""" async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request) as sse: sse.ping_interval = 999 request.protocol.force_close() with pytest.raises(ConnectionResetError): await sse.send("never-should-be-delivered") assert sse._ping_task is not None assert sse._ping_task.cancelled() return sse # pragma: no cover app = web.Application() app.router.add_route("GET", "/", handler) client = await aiohttp_client(app) async with client.get("/") as response: assert 200 == response.status async def test_context_manager(aiohttp_client: ClientFixture) -> None: async def func(request: web.Request) -> web.StreamResponse: h = {"X-SSE": "aiohttp_sse"} async with sse_response(request, headers=h) as sse: await sse.send("foo") await sse.send("foo", event="bar") await sse.send("foo", event="bar", id="xyz") await sse.send("foo", event="bar", id="xyz", retry=1) return sse app = web.Application() app.router.add_route("GET", "/", func) app.router.add_route("POST", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert resp.status == 200 # make sure that EventSourceResponse supports passing # custom headers assert resp.headers["X-SSE"] == "aiohttp_sse" # check streamed data streamed_data = await resp.text() expected = ( "data: foo\r\n\r\n" "event: bar\r\ndata: foo\r\n\r\n" "id: xyz\r\nevent: bar\r\ndata: foo\r\n\r\n" "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" ) assert streamed_data == expected class TestCustomResponseClass: async def test_subclass(self) -> None: class CustomEventSource(EventSourceResponse): pass request = make_mocked_request("GET", "/") await sse_response(request, response_cls=CustomEventSource) async def test_not_related_class(self) -> None: class CustomClass: pass request = make_mocked_request("GET", "/") with pytest.raises(TypeError): await sse_response( request=request, response_cls=CustomClass, # type: ignore[type-var] ) @pytest.mark.parametrize("sep", ["\n", "\r", "\r\n"], ids=("LF", "CR", "CR+LF")) async def test_custom_sep(aiohttp_client: ClientFixture, sep: str) -> None: async def func(request: web.Request) -> web.StreamResponse: h = {"X-SSE": "aiohttp_sse"} async with sse_response(request, headers=h, sep=sep) as sse: await sse.send("foo") await sse.send("foo", event="bar") await sse.send("foo", event="bar", id="xyz") await sse.send("foo", event="bar", id="xyz", retry=1) return sse app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert resp.status == 200 # make sure that EventSourceResponse supports passing # custom headers assert resp.headers["X-SSE"] == "aiohttp_sse" # check streamed data streamed_data = await resp.text() expected = ( "data: foo{0}{0}" "event: bar{0}data: foo{0}{0}" "id: xyz{0}event: bar{0}data: foo{0}{0}" "id: xyz{0}event: bar{0}data: foo{0}retry: 1{0}{0}" ) assert streamed_data == expected.format(sep) @pytest.mark.parametrize( "stream_sep,line_sep", [ ( "\n", "\n", ), ( "\n", "\r", ), ( "\n", "\r\n", ), ( "\r", "\n", ), ( "\r", "\r", ), ( "\r", "\r\n", ), ( "\r\n", "\n", ), ( "\r\n", "\r", ), ( "\r\n", "\r\n", ), ], ids=( "steam-LF:line-LF", "steam-LF:line-CR", "steam-LF:line-CR+LF", "steam-CR:line-LF", "steam-CR:line-CR", "steam-CR:line-CR+LF", "steam-CR+LF:line-LF", "steam-CR+LF:line-CR", "steam-CR+LF:line-CR+LF", ), ) async def test_multiline_data( aiohttp_client: ClientFixture, stream_sep: str, line_sep: str, ) -> None: async def func(request: web.Request) -> web.StreamResponse: h = {"X-SSE": "aiohttp_sse"} lines = line_sep.join(["foo", "bar", "xyz"]) async with sse_response(request, headers=h, sep=stream_sep) as sse: await sse.send(lines) await sse.send(lines, event="bar") await sse.send(lines, event="bar", id="xyz") await sse.send(lines, event="bar", id="xyz", retry=1) return sse app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert resp.status == 200 # make sure that EventSourceResponse supports passing # custom headers assert resp.headers["X-SSE"] == "aiohttp_sse" # check streamed data streamed_data = await resp.text() expected = ( "data: foo{0}data: bar{0}data: xyz{0}{0}" "event: bar{0}data: foo{0}data: bar{0}data: xyz{0}{0}" "id: xyz{0}event: bar{0}data: foo{0}data: bar{0}data: xyz{0}{0}" "id: xyz{0}event: bar{0}data: foo{0}data: bar{0}data: xyz{0}" "retry: 1{0}{0}" ) assert streamed_data == expected.format(stream_sep) class TestSSEState: async def test_context_states(self, aiohttp_client: ClientFixture) -> None: async def func(request: web.Request) -> web.StreamResponse: async with sse_response(request) as resp: assert resp.is_connected() assert not resp.is_connected() return resp app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert resp.status == 200 async def test_not_prepared(self) -> None: response = EventSourceResponse() assert not response.is_connected() async def test_connection_is_not_alive(aiohttp_client: ClientFixture) -> None: async def func(request: web.Request) -> web.StreamResponse: # within context manager first preparation is already done async with sse_response(request) as sse: request.protocol.force_close() # this call should be cancelled, cause connection is closed with pytest.raises(asyncio.CancelledError): await sse.prepare(request) return sse # pragma: no cover app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) async with client.get("/") as resp: assert resp.status == 200 class TestLastEventId: async def test_success(self, aiohttp_client: ClientFixture) -> None: async def func(request: web.Request) -> web.StreamResponse: async with sse_response(request) as sse: assert sse.last_event_id is not None await sse.send(sse.last_event_id) return sse app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) async with client.get("/") as resp: assert resp.status == 200 last_event_id = "42" headers = {EventSourceResponse.DEFAULT_LAST_EVENT_HEADER: last_event_id} async with client.get("/", headers=headers) as resp: assert resp.status == 200 # check streamed data streamed_data = await resp.text() assert streamed_data == f"data: {last_event_id}\r\n\r\n" async def test_get_before_prepare(self) -> None: sse = EventSourceResponse() with pytest.raises(RuntimeError): _ = sse.last_event_id @pytest.mark.parametrize( "http_method", ("GET", "POST", "PUT", "DELETE", "PATCH"), ) async def test_http_methods(aiohttp_client: ClientFixture, http_method: str) -> None: async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request) as sse: await sse.send("foo") return sse app = web.Application() app.router.add_route(http_method, "/", handler) client = await aiohttp_client(app) async with client.request(http_method, "/") as resp: assert resp.status == 200 # check streamed data streamed_data = await resp.text() assert streamed_data == "data: foo\r\n\r\n" @pytest.mark.skipif( sys.version_info < (3, 11), reason=".cancelling() missing in older versions", ) async def test_cancelled_not_swallowed(aiohttp_client: ClientFixture) -> None: """Test asyncio.CancelledError is not swallowed by .wait(). Relates to: https://github.com/aio-libs/aiohttp-sse/issues/458 """ async def endless_task(sse: EventSourceResponse) -> None: while True: await sse.wait() async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request) as sse: task = asyncio.create_task(endless_task(sse)) await asyncio.sleep(0) task.cancel() await task return sse # pragma: no cover app = web.Application() app.router.add_route("GET", "/", handler) client = await aiohttp_client(app) async with client.get("/") as response: assert 200 == response.status