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

Response from server:

65 |
66 | 67 | 68 | """ 69 | return web.Response(text=html, content_type="text/html") 70 | 71 | 72 | app = web.Application() 73 | app.router.add_route("GET", "/hello", hello) 74 | app.router.add_route("GET", "/", index) 75 | web.run_app(app, host="127.0.0.1", port=8080) 76 | 77 | 78 | EventSource Protocol 79 | -------------------- 80 | 81 | * http://www.w3.org/TR/2011/WD-eventsource-20110310/ 82 | * https://developer.mozilla.org/en-US/docs/Server-sent_events/Using_server-sent_events 83 | 84 | 85 | Requirements 86 | ------------ 87 | 88 | * aiohttp_ 3+ 89 | 90 | 91 | License 92 | ------- 93 | 94 | The *aiohttp-sse* is offered under Apache 2.0 license. 95 | 96 | .. _Python: https://www.python.org 97 | .. _asyncio: http://docs.python.org/3/library/asyncio.html 98 | .. _aiohttp: https://github.com/aio-libs/aiohttp 99 | -------------------------------------------------------------------------------- /aiohttp_sse/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import re 4 | import sys 5 | from collections.abc import Mapping 6 | from types import TracebackType 7 | from typing import Any, Optional, TypeVar, Union, overload 8 | 9 | from aiohttp.abc import AbstractStreamWriter 10 | from aiohttp.web import BaseRequest, ContentCoding, Request, StreamResponse 11 | 12 | from .helpers import _ContextManager 13 | 14 | __version__ = "2.2.0" 15 | __all__ = ["EventSourceResponse", "sse_response"] 16 | 17 | 18 | class EventSourceResponse(StreamResponse): 19 | """This object could be used as regular aiohttp response for 20 | streaming data to client, usually browser with EventSource:: 21 | 22 | async def hello(request): 23 | # create response object 24 | resp = await EventSourceResponse() 25 | async with resp: 26 | # stream data 27 | resp.send('foo') 28 | return resp 29 | """ 30 | 31 | DEFAULT_PING_INTERVAL = 15 32 | DEFAULT_SEPARATOR = "\r\n" 33 | DEFAULT_LAST_EVENT_HEADER = "Last-Event-Id" 34 | LINE_SEP_EXPR = re.compile(r"\r\n|\r|\n") 35 | 36 | def __init__( 37 | self, 38 | *, 39 | status: int = 200, 40 | reason: Optional[str] = None, 41 | headers: Optional[Mapping[str, str]] = None, 42 | sep: Optional[str] = None, 43 | ): 44 | super().__init__(status=status, reason=reason) 45 | 46 | if headers is not None: 47 | self.headers.extend(headers) 48 | 49 | # mandatory for servers-sent events headers 50 | self.headers["Content-Type"] = "text/event-stream" 51 | self.headers["Cache-Control"] = "no-cache" 52 | self.headers["Connection"] = "keep-alive" 53 | self.headers["X-Accel-Buffering"] = "no" 54 | 55 | self._ping_interval: float = self.DEFAULT_PING_INTERVAL 56 | self._ping_task: Optional[asyncio.Task[None]] = None 57 | self._sep = sep if sep is not None else self.DEFAULT_SEPARATOR 58 | 59 | def is_connected(self) -> bool: 60 | """Check connection is prepared and ping task is not done.""" 61 | if not self.prepared or self._ping_task is None: 62 | return False 63 | 64 | return not self._ping_task.done() 65 | 66 | async def _prepare(self, request: Request) -> "EventSourceResponse": 67 | # TODO(PY311): Use Self for return type. 68 | await self.prepare(request) 69 | return self 70 | 71 | async def prepare(self, request: BaseRequest) -> Optional[AbstractStreamWriter]: 72 | """Prepare for streaming and send HTTP headers. 73 | 74 | :param request: regular aiohttp.web.Request. 75 | """ 76 | if not self.prepared: 77 | writer = await super().prepare(request) 78 | self._ping_task = asyncio.create_task(self._ping()) 79 | # explicitly enabling chunked encoding, since content length 80 | # usually not known beforehand. 81 | self.enable_chunked_encoding() 82 | return writer 83 | else: 84 | # hackish way to check if connection alive 85 | # should be updated once we have proper API in aiohttp 86 | # https://github.com/aio-libs/aiohttp/issues/3105 87 | if request.protocol.transport is None: 88 | # request disconnected 89 | raise asyncio.CancelledError() 90 | return self._payload_writer 91 | 92 | async def send( 93 | self, 94 | data: str, 95 | id: Optional[str] = None, 96 | event: Optional[str] = None, 97 | retry: Optional[int] = None, 98 | ) -> None: 99 | """Send data using EventSource protocol 100 | 101 | :param str data: The data field for the message. 102 | :param str id: The event ID to set the EventSource object's last 103 | event ID value to. 104 | :param str event: The event's type. If this is specified, an event will 105 | be dispatched on the browser to the listener for the specified 106 | event name; the web site would use addEventListener() to listen 107 | for named events. The default event type is "message". 108 | :param int retry: The reconnection time to use when attempting to send 109 | the event. [What code handles this?] This must be an integer, 110 | specifying the reconnection time in milliseconds. If a non-integer 111 | value is specified, the field is ignored. 112 | """ 113 | buffer = io.StringIO() 114 | if id is not None: 115 | buffer.write(self.LINE_SEP_EXPR.sub("", f"id: {id}")) 116 | buffer.write(self._sep) 117 | 118 | if event is not None: 119 | buffer.write(self.LINE_SEP_EXPR.sub("", f"event: {event}")) 120 | buffer.write(self._sep) 121 | 122 | for chunk in self.LINE_SEP_EXPR.split(data): 123 | buffer.write(f"data: {chunk}") 124 | buffer.write(self._sep) 125 | 126 | if retry is not None: 127 | if not isinstance(retry, int): 128 | raise TypeError("retry argument must be int") 129 | buffer.write(f"retry: {retry}") 130 | buffer.write(self._sep) 131 | 132 | buffer.write(self._sep) 133 | try: 134 | await self.write(buffer.getvalue().encode("utf-8")) 135 | except ConnectionResetError: 136 | self.stop_streaming() 137 | raise 138 | 139 | async def wait(self) -> None: 140 | """EventSourceResponse object is used for streaming data to the client, 141 | this method returns future, so we can wait until connection will 142 | be closed or other task explicitly call ``stop_streaming`` method. 143 | """ 144 | if self._ping_task is None: 145 | raise RuntimeError("Response is not started") 146 | 147 | try: 148 | await self._ping_task 149 | except asyncio.CancelledError: 150 | if ( 151 | sys.version_info >= (3, 11) 152 | and (task := asyncio.current_task()) 153 | and task.cancelling() 154 | ): 155 | raise 156 | 157 | def stop_streaming(self) -> None: 158 | """Used in conjunction with ``wait`` could be called from other task 159 | to notify client that server no longer wants to stream anything. 160 | """ 161 | if self._ping_task is None: 162 | raise RuntimeError("Response is not started") 163 | self._ping_task.cancel() 164 | 165 | def enable_compression( 166 | self, 167 | force: Union[bool, ContentCoding, None] = False, 168 | strategy: Optional[int] = None, 169 | ) -> None: 170 | raise NotImplementedError 171 | 172 | @property 173 | def last_event_id(self) -> Optional[str]: 174 | """Last event ID, requested by client.""" 175 | if self._req is None: 176 | msg = "EventSource request must be prepared first" 177 | raise RuntimeError(msg) 178 | 179 | return self._req.headers.get(self.DEFAULT_LAST_EVENT_HEADER) 180 | 181 | @property 182 | def ping_interval(self) -> float: 183 | """Time interval between two ping massages""" 184 | return self._ping_interval 185 | 186 | @ping_interval.setter 187 | def ping_interval(self, value: float) -> None: 188 | """Setter for ping_interval property. 189 | 190 | :param value: interval in sec between two ping values. 191 | """ 192 | 193 | if not isinstance(value, (int, float)): 194 | raise TypeError("ping interval must be int or float") 195 | if value < 0: 196 | raise ValueError("ping interval must be greater then 0") 197 | 198 | self._ping_interval = value 199 | 200 | async def _ping(self) -> None: 201 | # periodically send ping to the browser. Any message that 202 | # starts with ":" colon ignored by a browser and could be used 203 | # as ping message. 204 | message = ": ping{0}{0}".format(self._sep).encode("utf-8") 205 | while True: 206 | await asyncio.sleep(self._ping_interval) 207 | try: 208 | await self.write(message) 209 | except (ConnectionResetError, RuntimeError): 210 | # RuntimeError - on writing after EOF 211 | break 212 | 213 | async def __aenter__(self) -> "EventSourceResponse": 214 | # TODO(PY311): Use Self 215 | return self 216 | 217 | async def __aexit__( 218 | self, 219 | exc_type: Optional[type[BaseException]], 220 | exc: Optional[BaseException], 221 | traceback: Optional[TracebackType], 222 | ) -> None: 223 | self.stop_streaming() 224 | await self.wait() 225 | 226 | 227 | # TODO(PY313): Use default and remove overloads. 228 | ESR = TypeVar("ESR", bound=EventSourceResponse) 229 | 230 | 231 | @overload 232 | def sse_response( 233 | request: Request, 234 | *, 235 | status: int = 200, 236 | reason: Optional[str] = None, 237 | headers: Optional[Mapping[str, str]] = None, 238 | sep: Optional[str] = None, 239 | ) -> _ContextManager[EventSourceResponse]: ... 240 | 241 | 242 | @overload 243 | def sse_response( 244 | request: Request, 245 | *, 246 | status: int = 200, 247 | reason: Optional[str] = None, 248 | headers: Optional[Mapping[str, str]] = None, 249 | sep: Optional[str] = None, 250 | response_cls: type[ESR], 251 | ) -> _ContextManager[ESR]: ... 252 | 253 | 254 | def sse_response( 255 | request: Request, 256 | *, 257 | status: int = 200, 258 | reason: Optional[str] = None, 259 | headers: Optional[Mapping[str, str]] = None, 260 | sep: Optional[str] = None, 261 | response_cls: type[EventSourceResponse] = EventSourceResponse, 262 | ) -> Any: 263 | if not issubclass(response_cls, EventSourceResponse): 264 | raise TypeError( 265 | "response_cls must be subclass of " 266 | "aiohttp_sse.EventSourceResponse, got {}".format(response_cls) 267 | ) 268 | 269 | sse = response_cls(status=status, reason=reason, headers=headers, sep=sep) 270 | return _ContextManager(sse._prepare(request)) 271 | -------------------------------------------------------------------------------- /aiohttp_sse/helpers.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Coroutine, Generator 2 | from types import TracebackType 3 | from typing import Any, AsyncContextManager, Optional, TypeVar 4 | 5 | T = TypeVar("T", bound=AsyncContextManager["T"]) # type: ignore[misc] 6 | 7 | 8 | class _ContextManager(Coroutine[T, None, T]): 9 | __slots__ = ("_coro", "_obj") 10 | 11 | def __init__(self, coro: Coroutine[T, None, T]) -> None: 12 | self._coro = coro 13 | self._obj: Optional[T] = None 14 | 15 | def send(self, arg: Any) -> T: 16 | return self._coro.send(arg) # pragma: no cover 17 | 18 | def throw(self, *args) -> T: # type: ignore[no-untyped-def] 19 | return self._coro.throw(*args) # pragma: no cover 20 | 21 | def close(self) -> None: 22 | return self._coro.close() # pragma: no cover 23 | 24 | @property 25 | def gi_frame(self) -> Any: 26 | return self._coro.gi_frame # type: ignore[attr-defined] # pragma: no cover 27 | 28 | @property 29 | def gi_running(self) -> Any: 30 | return self._coro.gi_running # type: ignore[attr-defined] # pragma: no cover 31 | 32 | @property 33 | def gi_code(self) -> Any: 34 | return self._coro.gi_code # type: ignore[attr-defined] # pragma: no cover 35 | 36 | def __next__(self) -> T: 37 | return self.send(None) # pragma: no cover 38 | 39 | def __await__(self) -> Generator[T, None, T]: 40 | return self._coro.__await__() 41 | 42 | async def __aenter__(self) -> T: 43 | self._obj = await self._coro 44 | return await self._obj.__aenter__() # type: ignore[no-any-return] 45 | 46 | async def __aexit__( 47 | self, 48 | exc_type: Optional[type[BaseException]], 49 | exc: Optional[BaseException], 50 | tb: Optional[TracebackType], 51 | ) -> Optional[bool]: 52 | if self._obj is None: # pragma: no cover 53 | return False 54 | return await self._obj.__aexit__(exc_type, exc, tb) 55 | -------------------------------------------------------------------------------- /aiohttp_sse/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-sse/bc3fc57313bdecc862599d375f963194f1ac9b4b/aiohttp_sse/py.typed -------------------------------------------------------------------------------- /examples/chat.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | 4 | from aiohttp import web 5 | 6 | from aiohttp_sse import EventSourceResponse, sse_response 7 | 8 | channels = web.AppKey("channels", set[asyncio.Queue[str]]) 9 | 10 | 11 | async def chat(_request: web.Request) -> web.Response: 12 | html = """ 13 | 14 | 15 | Tiny Chat 16 | 19 | 40 | 67 | 68 | 69 |
70 | 71 | Anonymous 72 | : 73 |
74 | 75 | 76 |
77 | 78 | 79 | 80 | """ 81 | return web.Response(text=html, content_type="text/html") 82 | 83 | 84 | async def message(request: web.Request) -> web.Response: 85 | app = request.app 86 | data = await request.post() 87 | 88 | for queue in app[channels]: 89 | payload = json.dumps(dict(data)) 90 | await queue.put(payload) 91 | return web.Response() 92 | 93 | 94 | async def subscribe(request: web.Request) -> EventSourceResponse: 95 | async with sse_response(request) as response: 96 | app = request.app 97 | queue: asyncio.Queue[str] = asyncio.Queue() 98 | print("Someone joined.") 99 | app[channels].add(queue) 100 | try: 101 | while response.is_connected(): 102 | payload = await queue.get() 103 | await response.send(payload) 104 | queue.task_done() 105 | finally: 106 | app[channels].remove(queue) 107 | print("Someone left.") 108 | return response 109 | 110 | 111 | if __name__ == "__main__": 112 | app = web.Application() 113 | app[channels] = set() 114 | 115 | app.router.add_route("GET", "/", chat) 116 | app.router.add_route("POST", "/everyone", message) 117 | app.router.add_route("GET", "/subscribe", subscribe) 118 | web.run_app(app, host="127.0.0.1", port=8080) 119 | -------------------------------------------------------------------------------- /examples/graceful_shutdown.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import weakref 5 | from collections.abc import Callable 6 | from contextlib import suppress 7 | from datetime import datetime 8 | from functools import partial 9 | from typing import Any, Optional 10 | 11 | from aiohttp import web 12 | 13 | from aiohttp_sse import EventSourceResponse, sse_response 14 | 15 | streams_key = web.AppKey("streams_key", weakref.WeakSet["SSEResponse"]) 16 | worker_key = web.AppKey("worker_key", asyncio.Task[None]) 17 | 18 | 19 | class SSEResponse(EventSourceResponse): 20 | async def send_json( 21 | self, 22 | data: dict[str, Any], 23 | id: Optional[str] = None, 24 | event: Optional[str] = None, 25 | retry: Optional[int] = None, 26 | json_dumps: Callable[[Any], str] = partial(json.dumps, indent=2), 27 | ) -> None: 28 | await self.send(json_dumps(data), id=id, event=event, retry=retry) 29 | 30 | 31 | async def send_event( 32 | stream: SSEResponse, 33 | data: dict[str, Any], 34 | event_id: str, 35 | ) -> None: 36 | try: 37 | await stream.send_json(data, id=event_id) 38 | except Exception: 39 | logging.exception("Exception when sending event: %s", event_id) 40 | 41 | 42 | async def worker(app: web.Application) -> None: 43 | while True: 44 | now = datetime.now() 45 | delay = asyncio.create_task(asyncio.sleep(1)) # Fire 46 | 47 | fs = [] 48 | for stream in app[streams_key]: 49 | data = { 50 | "time": f"Server Time : {now}", 51 | "last_event_id": stream.last_event_id, 52 | } 53 | coro = send_event(stream, data, str(now.timestamp())) 54 | fs.append(coro) 55 | 56 | # Run in parallel 57 | await asyncio.gather(*fs) 58 | 59 | # Sleep 1s - n 60 | await delay 61 | 62 | 63 | async def on_startup(app: web.Application) -> None: 64 | app[streams_key] = weakref.WeakSet[SSEResponse]() 65 | app[worker_key] = asyncio.create_task(worker(app)) 66 | 67 | 68 | async def clean_up(app: web.Application) -> None: 69 | app[worker_key].cancel() 70 | with suppress(asyncio.CancelledError): 71 | await app[worker_key] 72 | 73 | 74 | async def on_shutdown(app: web.Application) -> None: 75 | waiters = [] 76 | for stream in app[streams_key]: 77 | stream.stop_streaming() 78 | waiters.append(stream.wait()) 79 | 80 | await asyncio.gather(*waiters, return_exceptions=True) 81 | app[streams_key].clear() 82 | 83 | 84 | async def hello(request: web.Request) -> web.StreamResponse: 85 | stream: SSEResponse = await sse_response(request, response_cls=SSEResponse) 86 | request.app[streams_key].add(stream) 87 | try: 88 | await stream.wait() 89 | finally: 90 | request.app[streams_key].discard(stream) 91 | return stream 92 | 93 | 94 | async def index(_request: web.Request) -> web.StreamResponse: 95 | d = """ 96 | 97 | 98 | 104 | 105 | 106 |

Response from server:

107 |

108 |         
109 |     
110 |     """
111 |     return web.Response(text=d, content_type="text/html")
112 | 
113 | 
114 | if __name__ == "__main__":
115 |     app = web.Application()
116 | 
117 |     app.on_startup.append(on_startup)
118 |     app.on_shutdown.append(on_shutdown)
119 |     app.on_cleanup.append(clean_up)
120 | 
121 |     app.router.add_route("GET", "/hello", hello)
122 |     app.router.add_route("GET", "/", index)
123 |     web.run_app(app, host="127.0.0.1", port=8080)
124 | 


--------------------------------------------------------------------------------
/examples/simple.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | from datetime import datetime
 3 | 
 4 | from aiohttp import web
 5 | 
 6 | from aiohttp_sse import sse_response
 7 | 
 8 | 
 9 | async def hello(request: web.Request) -> web.StreamResponse:
10 |     async with sse_response(request) as resp:
11 |         while resp.is_connected():
12 |             data = f"Server Time : {datetime.now()}"
13 |             print(data)
14 |             await resp.send(data)
15 |             await asyncio.sleep(1)
16 |     return resp
17 | 
18 | 
19 | async def index(_request: web.Request) -> web.StreamResponse:
20 |     html = """
21 |         
22 |             
23 |                 
29 |                 

Response from server:

30 |
31 | 32 | 33 | """ 34 | return web.Response(text=html, content_type="text/html") 35 | 36 | 37 | if __name__ == "__main__": 38 | app = web.Application() 39 | app.router.add_route("GET", "/hello", hello) 40 | app.router.add_route("GET", "/", index) 41 | web.run_app(app, host="127.0.0.1", port=8080) 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=51", "wheel>=0.36"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | exclude = ''' 7 | /( 8 | \.git 9 | | venv 10 | | __pycache__ 11 | | \.tox 12 | )/ 13 | ''' 14 | 15 | [tool.towncrier] 16 | package = "aiohttp_sse" 17 | filename = "CHANGES.rst" 18 | directory = "CHANGES/" 19 | title_format = "{version} ({project_date})" 20 | issue_format = "`#{issue} `_" 21 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | # show 10 slowest invocations: 4 | --durations=10 5 | # a bit of verbosity doesn't hurt: 6 | -v 7 | # report all the things == -rxXs: 8 | -ra 9 | # show values of the local vars in errors: 10 | --showlocals 11 | # coverage reports 12 | --cov=aiohttp_sse/ --cov=tests/ --cov-report term 13 | asyncio_mode = auto 14 | filterwarnings = 15 | error 16 | testpaths = tests/ 17 | xfail_strict = true 18 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | mypy==1.16.0 3 | 4 | pre-commit==4.2.0 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | aiohttp==3.12.9 3 | pytest==8.4.0 4 | pytest-aiohttp==1.1.0 5 | pytest-asyncio==0.26.0 6 | pytest-cov==6.1.1 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # TODO: don't disable D*, fix up issues instead 3 | ignore = N801,N802,N803,E203,E226,E305,W504,E252,E301,E302,E704,W503,W504,F811,D1,D4 4 | max-line-length = 88 5 | 6 | [isort] 7 | profile = black 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import codecs 3 | import os 4 | import sys 5 | 6 | from setuptools import find_packages, setup 7 | 8 | PY_VER = sys.version_info 9 | 10 | if PY_VER < (3, 9): 11 | raise RuntimeError("aiohttp-sse doesn't support Python earlier than 3.9") 12 | 13 | 14 | def read(f): 15 | with codecs.open( 16 | os.path.join(os.path.dirname(__file__), f), encoding="utf-8" 17 | ) as ofile: 18 | return ofile.read() 19 | 20 | 21 | class VersionFinder(ast.NodeVisitor): 22 | def __init__(self): 23 | self.version = None 24 | 25 | def visit_Assign(self, node): 26 | if not self.version: 27 | if node.targets[0].id == "__version__": 28 | self.version = node.value.s 29 | 30 | 31 | def read_version(): 32 | init_py = os.path.join(os.path.dirname(__file__), "aiohttp_sse", "__init__.py") 33 | finder = VersionFinder() 34 | finder.visit(ast.parse(read(init_py))) 35 | if finder.version is None: 36 | msg = "Cannot find version in aiohttp_sse/__init__.py" 37 | raise RuntimeError(msg) 38 | return finder.version 39 | 40 | 41 | install_requires = ["aiohttp>=3.0"] 42 | 43 | 44 | setup( 45 | name="aiohttp-sse", 46 | version=read_version(), 47 | description=("Server-sent events support for aiohttp."), 48 | long_description=read("README.rst"), 49 | classifiers=[ 50 | "License :: OSI Approved :: Apache Software License", 51 | "Intended Audience :: Developers", 52 | "Programming Language :: Python", 53 | "Programming Language :: Python :: 3", 54 | "Programming Language :: Python :: 3.9", 55 | "Programming Language :: Python :: 3.10", 56 | "Programming Language :: Python :: 3.11", 57 | "Programming Language :: Python :: 3.12", 58 | "Programming Language :: Python :: 3.13", 59 | "Topic :: Internet :: WWW/HTTP", 60 | "Framework :: AsyncIO", 61 | "Framework :: aiohttp", 62 | ], 63 | author="Nikolay Novik", 64 | author_email="nickolainovik@gmail.com", 65 | url="https://github.com/aio-libs/aiohttp_sse/", 66 | license="Apache 2", 67 | python_requires=">=3.9", 68 | packages=find_packages(), 69 | install_requires=install_requires, 70 | include_package_data=True, 71 | ) 72 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from asyncio import AbstractEventLoop 2 | from typing import cast 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture( 8 | scope="session", 9 | params=[True, False], 10 | ids=["debug:true", "debug:false"], 11 | ) 12 | def debug(request: pytest.FixtureRequest) -> bool: 13 | return cast(bool, request.param) 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def loop(event_loop: AbstractEventLoop, debug: bool) -> AbstractEventLoop: 18 | event_loop.set_debug(debug) 19 | return event_loop 20 | -------------------------------------------------------------------------------- /tests/test_sse.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | import pytest 5 | from aiohttp import web 6 | from aiohttp.pytest_plugin import AiohttpClient 7 | from aiohttp.test_utils import make_mocked_request 8 | 9 | from aiohttp_sse import EventSourceResponse, sse_response 10 | 11 | socket = web.AppKey("socket", list[EventSourceResponse]) 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "with_sse_response", 16 | (False, True), 17 | ids=("without_sse_response", "with_sse_response"), 18 | ) 19 | async def test_func(with_sse_response: bool, aiohttp_client: AiohttpClient) -> None: 20 | async def func(request: web.Request) -> web.StreamResponse: 21 | if with_sse_response: 22 | resp = await sse_response(request, headers={"X-SSE": "aiohttp_sse"}) 23 | else: 24 | resp = EventSourceResponse(headers={"X-SSE": "aiohttp_sse"}) 25 | await resp.prepare(request) 26 | await resp.send("foo") 27 | await resp.send("foo", event="bar") 28 | await resp.send("foo", event="bar", id="xyz") 29 | await resp.send("foo", event="bar", id="xyz", retry=1) 30 | resp.stop_streaming() 31 | await resp.wait() 32 | return resp 33 | 34 | app = web.Application() 35 | app.router.add_route("GET", "/", func) 36 | app.router.add_route("POST", "/", func) 37 | 38 | client = await aiohttp_client(app) 39 | resp = await client.get("/") 40 | assert 200 == resp.status 41 | 42 | # make sure that EventSourceResponse supports passing 43 | # custom headers 44 | assert resp.headers.get("X-SSE") == "aiohttp_sse" 45 | 46 | # make sure default headers set 47 | assert resp.headers.get("Content-Type") == "text/event-stream" 48 | assert resp.headers.get("Cache-Control") == "no-cache" 49 | assert resp.headers.get("Connection") == "keep-alive" 50 | assert resp.headers.get("X-Accel-Buffering") == "no" 51 | 52 | # check streamed data 53 | streamed_data = await resp.text() 54 | expected = ( 55 | "data: foo\r\n\r\n" 56 | "event: bar\r\ndata: foo\r\n\r\n" 57 | "id: xyz\r\nevent: bar\r\ndata: foo\r\n\r\n" 58 | "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" 59 | ) 60 | assert streamed_data == expected 61 | 62 | 63 | async def test_wait_stop_streaming(aiohttp_client: AiohttpClient) -> None: 64 | async def func(request: web.Request) -> web.StreamResponse: 65 | app = request.app 66 | resp = EventSourceResponse() 67 | await resp.prepare(request) 68 | await resp.send("foo", event="bar", id="xyz", retry=1) 69 | app[socket].append(resp) 70 | await resp.wait() 71 | return resp 72 | 73 | app = web.Application() 74 | app[socket] = [] 75 | app.router.add_route("GET", "/", func) 76 | 77 | client = await aiohttp_client(app) 78 | resp_task = asyncio.create_task(client.get("/")) 79 | 80 | await asyncio.sleep(0.1) 81 | esourse = app[socket][0] 82 | esourse.stop_streaming() 83 | await esourse.wait() 84 | resp = await resp_task 85 | 86 | assert 200 == resp.status 87 | streamed_data = await resp.text() 88 | 89 | expected = "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" 90 | assert streamed_data == expected 91 | 92 | 93 | async def test_retry(aiohttp_client: AiohttpClient) -> None: 94 | async def func(request: web.Request) -> web.StreamResponse: 95 | resp = EventSourceResponse() 96 | await resp.prepare(request) 97 | with pytest.raises(TypeError): 98 | await resp.send("foo", retry="one") # type: ignore[arg-type] 99 | await resp.send("foo", retry=1) 100 | resp.stop_streaming() 101 | await resp.wait() 102 | return resp 103 | 104 | app = web.Application() 105 | app.router.add_route("GET", "/", func) 106 | 107 | client = await aiohttp_client(app) 108 | resp = await client.get("/") 109 | assert 200 == resp.status 110 | 111 | # check streamed data 112 | streamed_data = await resp.text() 113 | expected = "data: foo\r\nretry: 1\r\n\r\n" 114 | assert streamed_data == expected 115 | 116 | 117 | async def test_wait_stop_streaming_errors() -> None: 118 | response = EventSourceResponse() 119 | with pytest.raises(RuntimeError) as ctx: 120 | await response.wait() 121 | assert str(ctx.value) == "Response is not started" 122 | 123 | with pytest.raises(RuntimeError) as ctx: 124 | response.stop_streaming() 125 | assert str(ctx.value) == "Response is not started" 126 | 127 | 128 | def test_compression_not_implemented() -> None: 129 | response = EventSourceResponse() 130 | with pytest.raises(NotImplementedError): 131 | response.enable_compression() 132 | 133 | 134 | class TestPingProperty: 135 | @pytest.mark.parametrize("value", (25, 25.0, 0), ids=("int", "float", "zero int")) 136 | def test_success(self, value: float) -> None: 137 | response = EventSourceResponse() 138 | response.ping_interval = value 139 | assert response.ping_interval == value 140 | 141 | @pytest.mark.parametrize("value", [None, "foo"], ids=("None", "str")) 142 | def test_wrong_type(self, value: float) -> None: 143 | response = EventSourceResponse() 144 | with pytest.raises(TypeError) as ctx: 145 | response.ping_interval = value 146 | 147 | assert ctx.match("ping interval must be int or float") 148 | 149 | def test_negative_int(self) -> None: 150 | response = EventSourceResponse() 151 | with pytest.raises(ValueError) as ctx: 152 | response.ping_interval = -42 153 | 154 | assert ctx.match("ping interval must be greater then 0") 155 | 156 | def test_default_value(self) -> None: 157 | response = EventSourceResponse() 158 | assert response.ping_interval == response.DEFAULT_PING_INTERVAL 159 | 160 | 161 | async def test_ping(aiohttp_client: AiohttpClient) -> None: 162 | async def func(request: web.Request) -> web.StreamResponse: 163 | app = request.app 164 | resp = EventSourceResponse() 165 | resp.ping_interval = 1 166 | await resp.prepare(request) 167 | await resp.send("foo") 168 | app[socket].append(resp) 169 | await resp.wait() 170 | return resp 171 | 172 | app = web.Application() 173 | app[socket] = [] 174 | app.router.add_route("GET", "/", func) 175 | 176 | client = await aiohttp_client(app) 177 | resp_task = asyncio.create_task(client.get("/")) 178 | 179 | await asyncio.sleep(1.15) 180 | esourse = app[socket][0] 181 | esourse.stop_streaming() 182 | await esourse.wait() 183 | resp = await resp_task 184 | 185 | assert 200 == resp.status 186 | streamed_data = await resp.text() 187 | 188 | expected = "data: foo\r\n\r\n" + ": ping\r\n\r\n" 189 | assert streamed_data == expected 190 | 191 | 192 | async def test_ping_reset( 193 | aiohttp_client: AiohttpClient, 194 | monkeypatch: pytest.MonkeyPatch, 195 | ) -> None: 196 | async def func(request: web.Request) -> web.StreamResponse: 197 | app = request.app 198 | resp = EventSourceResponse() 199 | resp.ping_interval = 1 200 | await resp.prepare(request) 201 | await resp.send("foo") 202 | app[socket].append(resp) 203 | await resp.wait() 204 | return resp 205 | 206 | app = web.Application() 207 | app[socket] = [] 208 | app.router.add_route("GET", "/", func) 209 | 210 | client = await aiohttp_client(app) 211 | resp_task = asyncio.create_task(client.get("/")) 212 | 213 | await asyncio.sleep(1.15) 214 | esource = app[socket][0] 215 | 216 | def reset_error_write(data: str) -> None: 217 | raise ConnectionResetError("Cannot write to closing transport") 218 | 219 | assert esource._ping_task 220 | assert not esource._ping_task.done() 221 | monkeypatch.setattr(esource, "write", reset_error_write) 222 | await esource.wait() 223 | 224 | assert esource._ping_task.done() 225 | resp = await resp_task 226 | 227 | assert 200 == resp.status 228 | streamed_data = await resp.text() 229 | 230 | expected = "data: foo\r\n\r\n" + ": ping\r\n\r\n" 231 | assert streamed_data == expected 232 | 233 | 234 | async def test_ping_auto_close(aiohttp_client: AiohttpClient) -> None: 235 | """Test ping task automatically closed on send failure.""" 236 | 237 | async def handler(request: web.Request) -> EventSourceResponse: 238 | async with sse_response(request) as sse: 239 | sse.ping_interval = 999 240 | 241 | request.protocol.force_close() 242 | with pytest.raises(ConnectionResetError): 243 | await sse.send("never-should-be-delivered") 244 | 245 | assert sse._ping_task is not None 246 | assert sse._ping_task.cancelled() 247 | 248 | return sse # pragma: no cover 249 | 250 | app = web.Application() 251 | app.router.add_route("GET", "/", handler) 252 | 253 | client = await aiohttp_client(app) 254 | 255 | async with client.get("/") as response: 256 | assert 200 == response.status 257 | 258 | 259 | async def test_context_manager(aiohttp_client: AiohttpClient) -> None: 260 | async def func(request: web.Request) -> web.StreamResponse: 261 | h = {"X-SSE": "aiohttp_sse"} 262 | async with sse_response(request, headers=h) as sse: 263 | await sse.send("foo") 264 | await sse.send("foo", event="bar") 265 | await sse.send("foo", event="bar", id="xyz") 266 | await sse.send("foo", event="bar", id="xyz", retry=1) 267 | return sse 268 | 269 | app = web.Application() 270 | app.router.add_route("GET", "/", func) 271 | app.router.add_route("POST", "/", func) 272 | 273 | client = await aiohttp_client(app) 274 | resp = await client.get("/") 275 | assert resp.status == 200 276 | 277 | # make sure that EventSourceResponse supports passing 278 | # custom headers 279 | assert resp.headers["X-SSE"] == "aiohttp_sse" 280 | 281 | # check streamed data 282 | streamed_data = await resp.text() 283 | expected = ( 284 | "data: foo\r\n\r\n" 285 | "event: bar\r\ndata: foo\r\n\r\n" 286 | "id: xyz\r\nevent: bar\r\ndata: foo\r\n\r\n" 287 | "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" 288 | ) 289 | assert streamed_data == expected 290 | 291 | 292 | class TestCustomResponseClass: 293 | async def test_subclass(self) -> None: 294 | class CustomEventSource(EventSourceResponse): 295 | pass 296 | 297 | request = make_mocked_request("GET", "/") 298 | await sse_response(request, response_cls=CustomEventSource) 299 | 300 | async def test_not_related_class(self) -> None: 301 | class CustomClass: 302 | pass 303 | 304 | request = make_mocked_request("GET", "/") 305 | with pytest.raises(TypeError): 306 | await sse_response( 307 | request=request, 308 | response_cls=CustomClass, # type: ignore[type-var] 309 | ) 310 | 311 | 312 | @pytest.mark.parametrize("sep", ["\n", "\r", "\r\n"], ids=("LF", "CR", "CR+LF")) 313 | async def test_custom_sep(aiohttp_client: AiohttpClient, sep: str) -> None: 314 | async def func(request: web.Request) -> web.StreamResponse: 315 | h = {"X-SSE": "aiohttp_sse"} 316 | async with sse_response(request, headers=h, sep=sep) as sse: 317 | await sse.send("foo") 318 | await sse.send("foo", event="bar") 319 | await sse.send("foo", event="bar", id="xyz") 320 | await sse.send("foo", event="bar", id="xyz", retry=1) 321 | return sse 322 | 323 | app = web.Application() 324 | app.router.add_route("GET", "/", func) 325 | 326 | client = await aiohttp_client(app) 327 | resp = await client.get("/") 328 | assert resp.status == 200 329 | 330 | # make sure that EventSourceResponse supports passing 331 | # custom headers 332 | assert resp.headers["X-SSE"] == "aiohttp_sse" 333 | 334 | # check streamed data 335 | streamed_data = await resp.text() 336 | expected = ( 337 | "data: foo{0}{0}" 338 | "event: bar{0}data: foo{0}{0}" 339 | "id: xyz{0}event: bar{0}data: foo{0}{0}" 340 | "id: xyz{0}event: bar{0}data: foo{0}retry: 1{0}{0}" 341 | ) 342 | 343 | assert streamed_data == expected.format(sep) 344 | 345 | 346 | @pytest.mark.parametrize( 347 | "stream_sep,line_sep", 348 | [ 349 | ( 350 | "\n", 351 | "\n", 352 | ), 353 | ( 354 | "\n", 355 | "\r", 356 | ), 357 | ( 358 | "\n", 359 | "\r\n", 360 | ), 361 | ( 362 | "\r", 363 | "\n", 364 | ), 365 | ( 366 | "\r", 367 | "\r", 368 | ), 369 | ( 370 | "\r", 371 | "\r\n", 372 | ), 373 | ( 374 | "\r\n", 375 | "\n", 376 | ), 377 | ( 378 | "\r\n", 379 | "\r", 380 | ), 381 | ( 382 | "\r\n", 383 | "\r\n", 384 | ), 385 | ], 386 | ids=( 387 | "steam-LF:line-LF", 388 | "steam-LF:line-CR", 389 | "steam-LF:line-CR+LF", 390 | "steam-CR:line-LF", 391 | "steam-CR:line-CR", 392 | "steam-CR:line-CR+LF", 393 | "steam-CR+LF:line-LF", 394 | "steam-CR+LF:line-CR", 395 | "steam-CR+LF:line-CR+LF", 396 | ), 397 | ) 398 | async def test_multiline_data( 399 | aiohttp_client: AiohttpClient, 400 | stream_sep: str, 401 | line_sep: str, 402 | ) -> None: 403 | async def func(request: web.Request) -> web.StreamResponse: 404 | h = {"X-SSE": "aiohttp_sse"} 405 | lines = line_sep.join(["foo", "bar", "xyz"]) 406 | async with sse_response(request, headers=h, sep=stream_sep) as sse: 407 | await sse.send(lines) 408 | await sse.send(lines, event="bar") 409 | await sse.send(lines, event="bar", id="xyz") 410 | await sse.send(lines, event="bar", id="xyz", retry=1) 411 | return sse 412 | 413 | app = web.Application() 414 | app.router.add_route("GET", "/", func) 415 | 416 | client = await aiohttp_client(app) 417 | resp = await client.get("/") 418 | assert resp.status == 200 419 | 420 | # make sure that EventSourceResponse supports passing 421 | # custom headers 422 | assert resp.headers["X-SSE"] == "aiohttp_sse" 423 | 424 | # check streamed data 425 | streamed_data = await resp.text() 426 | expected = ( 427 | "data: foo{0}data: bar{0}data: xyz{0}{0}" 428 | "event: bar{0}data: foo{0}data: bar{0}data: xyz{0}{0}" 429 | "id: xyz{0}event: bar{0}data: foo{0}data: bar{0}data: xyz{0}{0}" 430 | "id: xyz{0}event: bar{0}data: foo{0}data: bar{0}data: xyz{0}" 431 | "retry: 1{0}{0}" 432 | ) 433 | assert streamed_data == expected.format(stream_sep) 434 | 435 | 436 | class TestSSEState: 437 | async def test_context_states(self, aiohttp_client: AiohttpClient) -> None: 438 | async def func(request: web.Request) -> web.StreamResponse: 439 | async with sse_response(request) as resp: 440 | assert resp.is_connected() 441 | 442 | assert not resp.is_connected() 443 | return resp 444 | 445 | app = web.Application() 446 | app.router.add_route("GET", "/", func) 447 | 448 | client = await aiohttp_client(app) 449 | resp = await client.get("/") 450 | assert resp.status == 200 451 | 452 | async def test_not_prepared(self) -> None: 453 | response = EventSourceResponse() 454 | assert not response.is_connected() 455 | 456 | 457 | async def test_connection_is_not_alive(aiohttp_client: AiohttpClient) -> None: 458 | async def func(request: web.Request) -> web.StreamResponse: 459 | # within context manager first preparation is already done 460 | async with sse_response(request) as sse: 461 | request.protocol.force_close() 462 | 463 | # this call should be cancelled, cause connection is closed 464 | with pytest.raises(asyncio.CancelledError): 465 | await sse.prepare(request) 466 | 467 | return sse # pragma: no cover 468 | 469 | app = web.Application() 470 | app.router.add_route("GET", "/", func) 471 | 472 | client = await aiohttp_client(app) 473 | async with client.get("/") as resp: 474 | assert resp.status == 200 475 | 476 | 477 | class TestLastEventId: 478 | async def test_success(self, aiohttp_client: AiohttpClient) -> None: 479 | async def func(request: web.Request) -> web.StreamResponse: 480 | async with sse_response(request) as sse: 481 | assert sse.last_event_id is not None 482 | await sse.send(sse.last_event_id) 483 | return sse 484 | 485 | app = web.Application() 486 | app.router.add_route("GET", "/", func) 487 | 488 | client = await aiohttp_client(app) 489 | async with client.get("/") as resp: 490 | assert resp.status == 200 491 | 492 | last_event_id = "42" 493 | headers = {EventSourceResponse.DEFAULT_LAST_EVENT_HEADER: last_event_id} 494 | async with client.get("/", headers=headers) as resp: 495 | assert resp.status == 200 496 | 497 | # check streamed data 498 | streamed_data = await resp.text() 499 | assert streamed_data == f"data: {last_event_id}\r\n\r\n" 500 | 501 | async def test_get_before_prepare(self) -> None: 502 | sse = EventSourceResponse() 503 | with pytest.raises(RuntimeError): 504 | _ = sse.last_event_id 505 | 506 | 507 | @pytest.mark.parametrize( 508 | "http_method", 509 | ("GET", "POST", "PUT", "DELETE", "PATCH"), 510 | ) 511 | async def test_http_methods(aiohttp_client: AiohttpClient, http_method: str) -> None: 512 | async def handler(request: web.Request) -> EventSourceResponse: 513 | async with sse_response(request) as sse: 514 | await sse.send("foo") 515 | return sse 516 | 517 | app = web.Application() 518 | app.router.add_route(http_method, "/", handler) 519 | 520 | client = await aiohttp_client(app) 521 | async with client.request(http_method, "/") as resp: 522 | assert resp.status == 200 523 | # check streamed data 524 | streamed_data = await resp.text() 525 | 526 | assert streamed_data == "data: foo\r\n\r\n" 527 | 528 | 529 | @pytest.mark.skipif( 530 | sys.version_info < (3, 11), 531 | reason=".cancelling() missing in older versions", 532 | ) 533 | async def test_cancelled_not_swallowed(aiohttp_client: AiohttpClient) -> None: 534 | """Test asyncio.CancelledError is not swallowed by .wait(). 535 | 536 | Relates to: 537 | https://github.com/aio-libs/aiohttp-sse/issues/458 538 | """ 539 | 540 | async def endless_task(sse: EventSourceResponse) -> None: 541 | while True: 542 | await sse.wait() 543 | 544 | async def handler(request: web.Request) -> EventSourceResponse: 545 | async with sse_response(request) as sse: 546 | task = asyncio.create_task(endless_task(sse)) 547 | await asyncio.sleep(0) 548 | task.cancel() 549 | await task 550 | 551 | return sse # pragma: no cover 552 | 553 | app = web.Application() 554 | app.router.add_route("GET", "/", handler) 555 | 556 | client = await aiohttp_client(app) 557 | 558 | async with client.get("/") as response: 559 | assert 200 == response.status 560 | --------------------------------------------------------------------------------