├── aiohttp_sse ├── py.typed ├── helpers.py └── __init__.py ├── requirements-dev.txt ├── MANIFEST.in ├── requirements.txt ├── .coveragerc ├── setup.cfg ├── .github ├── dependabot.yml └── workflows │ ├── auto-merge.yml │ ├── codeql.yml │ └── ci.yml ├── pytest.ini ├── pyproject.toml ├── LICENSE ├── tests ├── conftest.py └── test_sse.py ├── .gitignore ├── Makefile ├── .mypy.ini ├── examples ├── simple.py ├── chat.py └── graceful_shutdown.py ├── CONTRIBUTING.rst ├── .pre-commit-config.yaml ├── CHANGES.rst ├── setup.py └── README.rst /aiohttp_sse/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | mypy==1.19.1 3 | 4 | pre-commit==4.3.0 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGES.txt 3 | include README.rst 4 | graft aiohttp_sse 5 | global-exclude *.pyc *.swp 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | aiohttp==3.13.2 3 | pytest==8.4.2 4 | pytest-aiohttp==1.1.0 5 | pytest-asyncio==1.2.0 6 | pytest-cov==7.0.0 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from asyncio import AbstractEventLoop, get_running_loop 2 | from collections.abc import AsyncIterator 3 | from typing import cast 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture( 9 | scope="session", 10 | params=[True, False], 11 | ids=["debug:true", "debug:false"], 12 | ) 13 | def debug(request: pytest.FixtureRequest) -> bool: 14 | return cast(bool, request.param) 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | async def loop(debug: bool) -> AsyncIterator[AbstractEventLoop]: 19 | event_loop = get_running_loop() 20 | event_loop.set_debug(debug) 21 | yield event_loop 22 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: 'v6.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: '6.0.1' 12 | hooks: 13 | - id: isort 14 | - repo: https://github.com/psf/black 15 | rev: '25.9.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: 'v6.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.20.0' 47 | hooks: 48 | - id: pyupgrade 49 | args: ['--py39-plus'] 50 | - repo: https://github.com/PyCQA/flake8 51 | rev: '7.3.0' 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.value 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 | -------------------------------------------------------------------------------- /.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@v6 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v4 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@v4 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@v4 62 | with: 63 | category: "/language:${{matrix.language}}" 64 | -------------------------------------------------------------------------------- /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 | import json 37 | from datetime import datetime 38 | 39 | from aiohttp import web 40 | 41 | from aiohttp_sse import sse_response 42 | 43 | 44 | async def hello(request: web.Request) -> web.StreamResponse: 45 | async with sse_response(request) as resp: 46 | while resp.is_connected(): 47 | time_dict = {"time": f"Server Time : {datetime.now()}"} 48 | data = json.dumps(time_dict, indent=2) 49 | print(data) 50 | await resp.send(data) 51 | await asyncio.sleep(1) 52 | return resp 53 | 54 | 55 | async def index(_request: web.Request) -> web.StreamResponse: 56 | html = """ 57 | 58 | 59 | 65 |

Response from server:

66 |
67 | 68 | 69 | """ 70 | return web.Response(text=html, content_type="text/html") 71 | 72 | 73 | app = web.Application() 74 | app.router.add_route("GET", "/hello", hello) 75 | app.router.add_route("GET", "/", index) 76 | web.run_app(app, host="127.0.0.1", port=8080) 77 | 78 | 79 | EventSource Protocol 80 | -------------------- 81 | 82 | * http://www.w3.org/TR/2011/WD-eventsource-20110310/ 83 | * https://developer.mozilla.org/en-US/docs/Server-sent_events/Using_server-sent_events 84 | 85 | 86 | Requirements 87 | ------------ 88 | 89 | * aiohttp_ 3+ 90 | 91 | 92 | License 93 | ------- 94 | 95 | The *aiohttp-sse* is offered under Apache 2.0 license. 96 | 97 | .. _Python: https://www.python.org 98 | .. _asyncio: http://docs.python.org/3/library/asyncio.html 99 | .. _aiohttp: https://github.com/aio-libs/aiohttp 100 | -------------------------------------------------------------------------------- /.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@v6 24 | - name: Setup Python 25 | uses: actions/setup-python@v6 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@v6 49 | - name: Setup Python ${{ matrix.pyver }} 50 | uses: actions/setup-python@v6 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@v6 90 | - name: Setup Python 91 | uses: actions/setup-python@v6 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 | -------------------------------------------------------------------------------- /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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------