pax_global_header00006660000000000000000000000064152630121370014512gustar00rootroot0000000000000052 comment=8501047258311ea9fb8f93aafc6ec76152ea7396 aio-libs-aiohttp-sse-6f63ee2/000077500000000000000000000000001526301213700160715ustar00rootroot00000000000000aio-libs-aiohttp-sse-6f63ee2/.coveragerc000066400000000000000000000002651526301213700202150ustar00rootroot00000000000000# .coveragerc to control coverage.py [run] branch = True [report] exclude_also = ^\s*if TYPE_CHECKING: assert False : \.\.\.(\s*#.*)?$ ^ +\.\.\.$ pytest.fail\( aio-libs-aiohttp-sse-6f63ee2/.github/000077500000000000000000000000001526301213700174315ustar00rootroot00000000000000aio-libs-aiohttp-sse-6f63ee2/.github/dependabot.yml000066400000000000000000000003121526301213700222550ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" aio-libs-aiohttp-sse-6f63ee2/.github/workflows/000077500000000000000000000000001526301213700214665ustar00rootroot00000000000000aio-libs-aiohttp-sse-6f63ee2/.github/workflows/auto-merge.yml000066400000000000000000000011401526301213700242520ustar00rootroot00000000000000name: Dependabot auto-merge on: pull_request_target permissions: pull-requests: write contents: write jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} aio-libs-aiohttp-sse-6f63ee2/.github/workflows/ci.yml000066400000000000000000000057671526301213700226230ustar00rootroot00000000000000name: CI on: push: branches: - master - '[0-9].[0-9]+' # matches to backport branches, e.g. 3.6 tags: [ 'v*' ] pull_request: branches: - master - '[0-9].[0-9]+' jobs: lint: name: Linter runs-on: ubuntu-latest timeout-minutes: 5 outputs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v7 with: python-version: 3.11 cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements-dev.txt - name: Run linters run: | make lint test: name: Test strategy: matrix: pyver: ['3.11', '3.12', '3.13', '3.14', '3.15'] os: [ubuntu, macos, windows] runs-on: ${{ matrix.os }}-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v7 - name: Setup Python ${{ matrix.pyver }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.pyver }} allow-prereleases: true cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements.txt - name: Run unittests run: pytest tests env: COLOR: 'yes' - run: python -m coverage xml - name: Upload coverage uses: codecov/codecov-action@v7 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} check: # This job does nothing and is only used for the branch protection if: always() needs: [lint, test] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} deploy: name: Deploy on PyPI needs: check environment: release runs-on: ubuntu-latest # Run only on pushing a tag if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') steps: - name: Checkout uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v7 with: python-version: 3.11 - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements-dev.txt - name: Update build deps run: | pip install -U build twine - name: Build dists run: | python -m build - name: Make Release uses: aio-libs/create-release@v1.6.6 with: changes_file: CHANGES.rst name: aiohttp-sse version_file: aiohttp_sse/__init__.py github_token: ${{ secrets.GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_TOKEN }} dist_dir: dist fix_issue_regex: "`#(\\d+) `_" fix_issue_repl: "#\\1" aio-libs-aiohttp-sse-6f63ee2/.github/workflows/codeql.yml000066400000000000000000000044461526301213700234700ustar00rootroot00000000000000name: "CodeQL" on: push: branches: [ 'master' ] pull_request: # The branches below must be a subset of the branches above branches: [ 'master' ] schedule: - cron: '18 1 * * 4' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4.38.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v4.38.2 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4.38.2 with: category: "/language:${{matrix.language}}" aio-libs-aiohttp-sse-6f63ee2/.gitignore000066400000000000000000000013271526301213700200640ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .cache nosetests.xml coverage.xml # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ # PyCharm .idea coverage/ cover/ .python-version aio-libs-aiohttp-sse-6f63ee2/.mypy.ini000066400000000000000000000016701526301213700176520ustar00rootroot00000000000000[mypy] files = aiohttp_sse, tests, examples check_untyped_defs = True follow_imports_for_stubs = True disallow_any_decorated = True disallow_any_generics = True disallow_any_unimported = True disallow_incomplete_defs = True disallow_subclassing_any = True disallow_untyped_calls = True disallow_untyped_decorators = True disallow_untyped_defs = True # TODO(PY312): explicit-override enable_error_code = ignore-without-code, possibly-undefined, redundant-expr, redundant-self, truthy-bool, truthy-iterable, unused-awaitable extra_checks = True implicit_reexport = False no_implicit_optional = True pretty = True show_column_numbers = True show_error_codes = True show_error_code_links = True strict_equality = True warn_incomplete_stub = True warn_redundant_casts = True warn_return_any = True warn_unreachable = True warn_unused_ignores = True [mypy-tests.*] disallow_any_decorated = False disallow_untyped_calls = False disallow_untyped_defs = False aio-libs-aiohttp-sse-6f63ee2/.pre-commit-config.yaml000066400000000000000000000026741526301213700223630ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v6.0.0' hooks: - id: check-merge-conflict - repo: https://github.com/asottile/yesqa rev: v1.5.0 hooks: - id: yesqa - repo: https://github.com/PyCQA/isort rev: '9.0.2' hooks: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror rev: '26.10.0' hooks: - id: black language_version: python3 # Should be a command that runs python3.6+ - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v6.0.0' hooks: - id: end-of-file-fixer - id: requirements-txt-fixer - id: trailing-whitespace - id: file-contents-sorter files: | docs/spelling_wordlist.txt| .gitignore| .gitattributes - id: check-case-conflict - id: check-json - id: check-xml - id: check-executables-have-shebangs - id: check-toml - id: check-xml - id: check-yaml - id: debug-statements - id: check-added-large-files - id: check-symlinks - id: debug-statements - id: detect-aws-credentials args: ['--allow-missing-credentials'] - id: detect-private-key exclude: ^examples/ - repo: https://github.com/asottile/pyupgrade rev: 'v3.21.2' hooks: - id: pyupgrade args: ['--py311-plus'] - repo: https://github.com/PyCQA/flake8 rev: '7.4.1' hooks: - id: flake8 exclude: "^docs/" - repo: https://github.com/Lucas-C/pre-commit-hooks-markup rev: v1.0.1 hooks: - id: rst-linter files: >- ^[^/]+[.]rst$ aio-libs-aiohttp-sse-6f63ee2/CHANGES.rst000066400000000000000000000063051526301213700176770ustar00rootroot00000000000000======= CHANGES ======= .. towncrier release notes start 3.0 (2026-10-11) ================ - Added ``send_timeout`` parameter to ``EventSourceResponse`` and ``sse_response()`` so a stalled client can no longer block ``send()`` forever: a write that exceeds the timeout raises ``TimeoutError`` and aborts the connection. On Linux the timeout is also applied at the TCP level (``TCP_USER_TIMEOUT``), so a dead peer is detected even between sends as pings go unacknowledged. Defaults to 120 seconds; pass ``None`` to restore the previous unbounded behaviour. - Disallow HTTP methods other than GET by default. Allowing other methods can be done by passing ``allow_all_methods`` to ``EventSourceResponse``. Note that SSE on browsers will only work with GET endpoints, this flag just serves as a reminder. - Added ``ping_interval`` parameter to ``EventSourceResponse`` and ``sse_response()`` for configuring the interval between keep-alive pings. - Removed the ``EventSourceResponse.ping_interval`` property. The ping interval can now only be set via the ``ping_interval`` parameter when creating the response. - ``send()`` now raises ``ValueError`` if the ``id`` or ``event`` fields contain line separators (or null characters for ``id``), instead of silently stripping them. - ``send()`` now raises ``ValueError`` for a negative ``retry`` instead of sending a value that clients would ignore. - ``sse_response()`` is now usable only as an async context manager; the ``resp = await sse_response(...)`` form has been removed. If ``async with`` must be avoided for some reason, use something like ``contextlib.AsyncExitStack``. - Dropped support for Python 3.8, 3.9 and 3.10; added support for Python 3.13, 3.14 and 3.15. 2.2.0 (2024-02-29) ================== - Added typing support. - Added ``EventSourceResponse.is_connected()`` method. - Added ``EventSourceResponse.last_event_id`` attribute. - Added support for SSE with HTTP methods other than GET. - Added support for float ping intervals. - Fixed (on Python 3.11+) ``EventSourceResponse.wait()`` swallowing user cancellation. - Fixed ping task not getting cancelled after a send failure. - Cancelled the ping task when a connection error occurs to help avoid errors. - Dropped support for Python 3.7 while adding support upto Python 3.12. 2.1.0 (2021-11-13) ================== Features -------- - Added Python 3.10 support (`#314 `_) Deprecations and Removals ------------------------- - Drop Python 3.6 support (`#319 `_) Misc ---- - `#163 `_ 2.0.0 (2018-02-19) ================== - Drop aiohttp < 3 support - ``EventSourceResponse.send`` is now a coroutine. 1.1.0 (2017-08-21) ================== - Drop python 3.4 support - Add new context manager API 1.0.0 (2017-04-14) ================== - Release aiohttp-sse==1.0.0 0.1.0 (2017-03-23) ================== - add support for asynchronous context manager interface - tests refactoring - modernize internal api to align with aiohttp 0.0.2 (2017-01-13) ================== - Added MANIFEST.in 0.0.1 (2017-01-13) ================== - Initial release aio-libs-aiohttp-sse-6f63ee2/CONTRIBUTING.rst000066400000000000000000000025401526301213700205330ustar00rootroot00000000000000Contributing ============ Running Tests ------------- .. _GitHub: https://github.com/aio-libs/aiohttp-sse Thanks for your interest in contributing to ``aiohttp-sse``, there are multiple ways and places you can contribute. Fist of all just clone repository:: $ git clone git@github.com:aio-libs/aiohttp-sse.git Create virtualenv with python. For example using *virtualenvwrapper* commands could look like:: $ cd aiohttp-sse $ mkvirtualenv --python=`which python3.12` aiohttp-sse After that please install libraries required for development:: $ pip install -r requirements-dev.txt $ pip install -e . Congratulations, you are ready to run the test suite:: $ make cov To run individual test use following command:: $ py.test -sv tests/test_sse.py -k test_name Reporting an Issue ------------------ If you have found issue with `aiohttp-sse` please do not hesitate to file an issue on the GitHub_ project. When filing your issue please make sure you can express the issue with a reproducible test case. When reporting an issue we also need as much information about your environment that you can include. We never know what information will be pertinent when trying narrow down the issue. Please include at least the following information: * Versions of `aiohttp-sse`, `aiohttp` and `python`. * Platform you're running on (OS X, Linux). aio-libs-aiohttp-sse-6f63ee2/LICENSE000066400000000000000000000260611526301213700171030ustar00rootroot00000000000000Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright aio-libs contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. aio-libs-aiohttp-sse-6f63ee2/MANIFEST.in000066400000000000000000000001441526301213700176260ustar00rootroot00000000000000include LICENSE include CHANGES.txt include README.rst graft aiohttp_sse global-exclude *.pyc *.swp aio-libs-aiohttp-sse-6f63ee2/Makefile000066400000000000000000000016061526301213700175340ustar00rootroot00000000000000# Some simple testing tasks (sorry, UNIX only). setup: pip install -r requirements-dev.txt pre-commit install .PHONY: fmt fmt: python -m pre_commit run --all-files --show-diff-on-failure .PHONY: lint lint: fmt mypy test: pytest -sv tests/ cov cover coverage: pytest -sv tests/ --cov=aiohttp_sse --cov-report=html @echo "open file://`pwd`/htmlcov/index.html" clean: rm -rf `find . -name __pycache__` rm -f `find . -type f -name '*.py[co]' ` rm -f `find . -type f -name '*~' ` rm -f `find . -type f -name '.*~' ` rm -f `find . -type f -name '@*' ` rm -f `find . -type f -name '#*#' ` rm -f `find . -type f -name '*.orig' ` rm -f `find . -type f -name '*.rej' ` rm -f .coverage rm -rf coverage rm -rf build rm -rf cover rm -rf htmlcov doc: make -C docs html @echo "open file://`pwd`/docs/_build/html/index.html" .PHONY: all build venv flake test vtest testloop cov clean doc aio-libs-aiohttp-sse-6f63ee2/README.rst000066400000000000000000000054361526301213700175700ustar00rootroot00000000000000aiohttp-sse =========== .. image:: https://github.com/aio-libs/aiohttp-sse/workflows/CI/badge.svg?event=push :target: https://github.com/aio-libs/aiohttp-sse/actions?query=event%3Apush+branch%3Amaster .. image:: https://codecov.io/gh/aio-libs/aiohttp-sse/branch/master/graph/badge.svg :target: https://codecov.io/gh/aio-libs/aiohttp-sse .. image:: https://pyup.io/repos/github/aio-libs/aiohttp-sse/shield.svg :target: https://pyup.io/repos/github/aio-libs/aiohttp-sse/ :alt: Updates .. image:: https://badges.gitter.im/Join%20Chat.svg :target: https://gitter.im/aio-libs/Lobby :alt: Chat on Gitter The **EventSource** interface is used to receive server-sent events. It connects to a server over HTTP and receives events in text/event-stream format without closing the connection. *aiohttp-sse* provides support for server-sent events for aiohttp_. Installation ------------ Installation process as simple as:: $ pip install aiohttp-sse Example ------- .. code:: python import asyncio import json from datetime import datetime from aiohttp import web from aiohttp_sse import sse_response async def hello(request: web.Request) -> web.StreamResponse: async with sse_response(request) as resp: while resp.is_connected(): time_dict = {"time": f"Server Time : {datetime.now()}"} data = json.dumps(time_dict, indent=2) print(data) await resp.send(data) await asyncio.sleep(1) return resp async def index(_request: web.Request) -> web.StreamResponse: html = """

Response from server:

""" return web.Response(text=html, content_type="text/html") app = web.Application() app.router.add_route("GET", "/hello", hello) app.router.add_route("GET", "/", index) web.run_app(app, host="127.0.0.1", port=8080) EventSource Protocol -------------------- * http://www.w3.org/TR/2011/WD-eventsource-20110310/ * https://developer.mozilla.org/en-US/docs/Server-sent_events/Using_server-sent_events Requirements ------------ * aiohttp_ 3+ License ------- The *aiohttp-sse* is offered under Apache 2.0 license. .. _Python: https://www.python.org .. _asyncio: http://docs.python.org/3/library/asyncio.html .. _aiohttp: https://github.com/aio-libs/aiohttp aio-libs-aiohttp-sse-6f63ee2/aiohttp_sse/000077500000000000000000000000001526301213700204135ustar00rootroot00000000000000aio-libs-aiohttp-sse-6f63ee2/aiohttp_sse/__init__.py000066400000000000000000000305271526301213700225330ustar00rootroot00000000000000import asyncio import io import re import socket from collections.abc import AsyncIterator, Mapping from contextlib import AbstractAsyncContextManager, asynccontextmanager from types import TracebackType from typing import Any, Self, TypeVar, overload from aiohttp.abc import AbstractStreamWriter from aiohttp.web import BaseRequest, ContentCoding, Request, StreamResponse from aiohttp.web_exceptions import HTTPMethodNotAllowed __version__ = "3.0" __all__ = ["EventSourceResponse", "sse_response"] _TCP_USER_TIMEOUT = getattr(socket, "TCP_USER_TIMEOUT", None) class EventSourceResponse(StreamResponse): """This object could be used as regular aiohttp response for streaming data to client, usually browser with EventSource:: async def hello(request): resp = EventSourceResponse() await resp.prepare(request) async with resp: # stream data await resp.send('foo') return resp """ DEFAULT_PING_INTERVAL: float = 15 DEFAULT_SEND_TIMEOUT: float | None = 120 DEFAULT_SEPARATOR = "\r\n" DEFAULT_LAST_EVENT_HEADER = "Last-Event-Id" LINE_SEP_EXPR = re.compile(r"\r\n|\r|\n") def __init__( self, *, status: int = 200, reason: str | None = None, headers: Mapping[str, str] | None = None, sep: str | None = None, allow_all_methods: bool = False, send_timeout: float | None = DEFAULT_SEND_TIMEOUT, ping_interval: float = DEFAULT_PING_INTERVAL, ): super().__init__(status=status, reason=reason) if headers is not None: self.headers.extend(headers) # mandatory for servers-sent events headers self.headers["Content-Type"] = "text/event-stream" self.headers["Cache-Control"] = "no-cache" self.headers["Connection"] = "keep-alive" self.headers["X-Accel-Buffering"] = "no" if ping_interval <= 0: raise ValueError("ping interval must be positive") self._ping_interval: float = ping_interval self._ping_task: asyncio.Task[None] | None = None self._sep = sep if sep is not None else self.DEFAULT_SEPARATOR self._allow_all_methods = allow_all_methods self._send_timeout = send_timeout self._saved_tcp_user_timeout: int | None = None self._tcp_sock: socket.socket | None = None def is_connected(self) -> bool: """Check connection is prepared and ping task is not done.""" if not self.prepared or self._ping_task is None: return False return not self._ping_task.done() async def prepare(self, request: BaseRequest) -> AbstractStreamWriter | None: """Prepare for streaming and send HTTP headers. :param request: regular aiohttp.web.Request. """ if not self._allow_all_methods and request.method != "GET": raise HTTPMethodNotAllowed(request.method, ("GET",)) if not self.prepared: self._set_tcp_user_timeout(request) writer = await super().prepare(request) self._ping_task = asyncio.create_task(self._ping()) # explicitly enabling chunked encoding, since content length # usually not known beforehand. self.enable_chunked_encoding() return writer else: # hackish way to check if connection alive # should be updated once we have proper API in aiohttp # https://github.com/aio-libs/aiohttp/issues/3105 if request.protocol.transport is None: # request disconnected raise asyncio.CancelledError() return self._payload_writer def _set_tcp_user_timeout(self, request: BaseRequest) -> None: """Ask the kernel to abort the connection when transmitted data remains unacknowledged for ``send_timeout`` (Linux only). This detects a hung peer as soon as any write goes unacknowledged (including the periodic pings), long before enough data queues up for ``send()`` to block on flow control - which, with autotuned kernel buffers, can take days for a low-rate stream. """ if self._send_timeout is None or _TCP_USER_TIMEOUT is None: return assert request.transport is not None sock = request.transport.get_extra_info("socket") if sock is None or sock.family not in (socket.AF_INET, socket.AF_INET6): return self._saved_tcp_user_timeout = sock.getsockopt( socket.IPPROTO_TCP, _TCP_USER_TIMEOUT ) self._tcp_sock = sock sock.setsockopt( socket.IPPROTO_TCP, _TCP_USER_TIMEOUT, int(self._send_timeout * 1000) ) def _restore_tcp_user_timeout(self) -> None: if self._saved_tcp_user_timeout is None: return assert self._tcp_sock is not None assert _TCP_USER_TIMEOUT is not None saved = self._saved_tcp_user_timeout if self._tcp_sock.fileno() >= 0: # Not restorable once the socket is closed. self._tcp_sock.setsockopt(socket.IPPROTO_TCP, _TCP_USER_TIMEOUT, saved) self._tcp_sock = None self._saved_tcp_user_timeout = None async def write_eof(self, data: bytes = b"") -> None: await super().write_eof(data) self._restore_tcp_user_timeout() async def send( self, data: str, id: str | None = None, event: str | None = None, retry: int | None = None, ) -> None: """Send data using EventSource protocol :param str data: The data field for the message. :param str id: The event ID to set the EventSource object's last event ID value to. Must not contain line separators or null characters. :param str event: The event's type. If this is specified, an event will be dispatched on the browser to the listener for the specified event name; the web site would use addEventListener() to listen for named events. The default event type is "message". Must not contain line separators. :param int retry: The reconnection time to use when attempting to send the event, in milliseconds. Must not be negative. Raises ``TimeoutError`` and aborts the connection if the write exceeds the response's ``send_timeout`` (i.e. a stalled client). Defaults to ``DEFAULT_SEND_TIMEOUT``; pass ``send_timeout=None`` to wait on a stalled client indefinitely. """ buffer = io.StringIO() if id is not None: if self.LINE_SEP_EXPR.search(id) or "\0" in id: raise ValueError( "id must not contain line separators or null characters" ) buffer.write(f"id: {id}") buffer.write(self._sep) if event is not None: if self.LINE_SEP_EXPR.search(event): raise ValueError("event must not contain line separators") buffer.write(f"event: {event}") buffer.write(self._sep) for chunk in self.LINE_SEP_EXPR.split(data): buffer.write(f"data: {chunk}") buffer.write(self._sep) if retry is not None: if retry < 0: raise ValueError("retry must not be negative") buffer.write(f"retry: {retry}") buffer.write(self._sep) buffer.write(self._sep) try: async with asyncio.timeout(self._send_timeout): await self.write(buffer.getvalue().encode("utf-8")) except ConnectionResetError: self.stop_streaming() raise except TimeoutError: self.stop_streaming() self._abort_transport() raise async def wait(self) -> None: """EventSourceResponse object is used for streaming data to the client, this method returns future, so we can wait until connection will be closed or other task explicitly call ``stop_streaming`` method. """ if self._ping_task is None: raise RuntimeError("Response is not started") try: await self._ping_task except asyncio.CancelledError: if (task := asyncio.current_task()) and task.cancelling(): raise def stop_streaming(self) -> None: """Used in conjunction with ``wait`` could be called from other task to notify client that server no longer wants to stream anything. """ if self._ping_task is None: raise RuntimeError("Response is not started") self._ping_task.cancel() def _abort_transport(self) -> None: """Abort the transport. A stalled peer never drains the write buffer, so a graceful close would block indefinitely; abort instead. """ assert self._req is not None if (transport := self._req.transport) is not None: transport.abort() def enable_compression( self, force: bool | ContentCoding | None = None, strategy: int | None = None, ) -> None: raise NotImplementedError @property def last_event_id(self) -> str | None: """Last event ID, requested by client.""" if self._req is None: msg = "EventSource request must be prepared first" raise RuntimeError(msg) return self._req.headers.get(self.DEFAULT_LAST_EVENT_HEADER) async def _ping(self) -> None: # periodically send ping to the browser. Any message that # starts with ":" colon ignored by a browser and could be used # as ping message. message = f": ping{self._sep}{self._sep}".encode() while True: await asyncio.sleep(self._ping_interval) try: async with asyncio.timeout(self._send_timeout): await self.write(message) except TimeoutError: self._abort_transport() break except (ConnectionResetError, RuntimeError): # RuntimeError - on writing after EOF break async def __aenter__(self) -> Self: return self async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: TracebackType | None, ) -> None: self.stop_streaming() await self.wait() # TODO(PY313): Use default and remove overloads. ESR = TypeVar("ESR", bound=EventSourceResponse) @overload def sse_response( request: Request, *, status: int = 200, reason: str | None = None, headers: Mapping[str, str] | None = None, sep: str | None = None, allow_all_methods: bool = False, send_timeout: float | None = EventSourceResponse.DEFAULT_SEND_TIMEOUT, ping_interval: float = EventSourceResponse.DEFAULT_PING_INTERVAL, ) -> AbstractAsyncContextManager[EventSourceResponse]: ... @overload def sse_response( request: Request, *, status: int = 200, reason: str | None = None, headers: Mapping[str, str] | None = None, sep: str | None = None, allow_all_methods: bool = False, send_timeout: float | None = EventSourceResponse.DEFAULT_SEND_TIMEOUT, ping_interval: float = EventSourceResponse.DEFAULT_PING_INTERVAL, response_cls: type[ESR], ) -> AbstractAsyncContextManager[ESR]: ... def sse_response( request: Request, *, status: int = 200, reason: str | None = None, headers: Mapping[str, str] | None = None, sep: str | None = None, send_timeout: float | None = EventSourceResponse.DEFAULT_SEND_TIMEOUT, ping_interval: float = EventSourceResponse.DEFAULT_PING_INTERVAL, response_cls: type[EventSourceResponse] = EventSourceResponse, allow_all_methods: bool = False, ) -> Any: if not issubclass(response_cls, EventSourceResponse): raise TypeError( "response_cls must be subclass of " f"aiohttp_sse.EventSourceResponse, got {response_cls}" ) sse = response_cls( status=status, reason=reason, headers=headers, sep=sep, allow_all_methods=allow_all_methods, send_timeout=send_timeout, ping_interval=ping_interval, ) return _stream(sse, request) @asynccontextmanager async def _stream(sse: ESR, request: Request) -> AsyncIterator[ESR]: await sse.prepare(request) async with sse as resp: yield resp aio-libs-aiohttp-sse-6f63ee2/aiohttp_sse/py.typed000066400000000000000000000000001526301213700221000ustar00rootroot00000000000000aio-libs-aiohttp-sse-6f63ee2/examples/000077500000000000000000000000001526301213700177075ustar00rootroot00000000000000aio-libs-aiohttp-sse-6f63ee2/examples/chat.py000066400000000000000000000072411526301213700212040ustar00rootroot00000000000000import asyncio import json from aiohttp import web from aiohttp_sse import EventSourceResponse, sse_response channels = web.AppKey("channels", set[asyncio.Queue[str]]) async def chat(_request: web.Request) -> web.Response: html = """ Tiny Chat
Anonymous :
""" return web.Response(text=html, content_type="text/html") async def message(request: web.Request) -> web.Response: app = request.app data = await request.post() for queue in app[channels]: payload = json.dumps(dict(data)) await queue.put(payload) return web.Response() async def subscribe(request: web.Request) -> EventSourceResponse: async with sse_response(request) as response: app = request.app queue: asyncio.Queue[str] = asyncio.Queue() print("Someone joined.") app[channels].add(queue) try: while response.is_connected(): payload = await queue.get() await response.send(payload) queue.task_done() finally: app[channels].remove(queue) print("Someone left.") return response if __name__ == "__main__": app = web.Application() app[channels] = set() app.router.add_route("GET", "/", chat) app.router.add_route("POST", "/everyone", message) app.router.add_route("GET", "/subscribe", subscribe) web.run_app(app, host="127.0.0.1", port=8080) aio-libs-aiohttp-sse-6f63ee2/examples/graceful_shutdown.py000066400000000000000000000065111526301213700240070ustar00rootroot00000000000000import asyncio import json import logging import weakref from collections.abc import Callable from contextlib import suppress from datetime import datetime from functools import partial from typing import Any from aiohttp import web from aiohttp_sse import EventSourceResponse, sse_response streams_key = web.AppKey("streams_key", weakref.WeakSet["SSEResponse"]) worker_key = web.AppKey("worker_key", asyncio.Task[None]) class SSEResponse(EventSourceResponse): async def send_json( self, data: dict[str, Any], id: str | None = None, event: str | None = None, retry: int | None = None, json_dumps: Callable[[Any], str] = partial(json.dumps, indent=2), ) -> None: await self.send(json_dumps(data), id=id, event=event, retry=retry) async def send_event( stream: SSEResponse, data: dict[str, Any], event_id: str, ) -> None: try: await stream.send_json(data, id=event_id) except Exception: logging.exception("Exception when sending event: %s", event_id) async def worker(app: web.Application) -> None: while True: now = datetime.now() delay = asyncio.create_task(asyncio.sleep(1)) # Fire fs = [] for stream in app[streams_key]: data = { "time": f"Server Time : {now}", "last_event_id": stream.last_event_id, } coro = send_event(stream, data, str(now.timestamp())) fs.append(coro) # Run in parallel await asyncio.gather(*fs) # Sleep 1s - n await delay async def on_startup(app: web.Application) -> None: app[streams_key] = weakref.WeakSet[SSEResponse]() app[worker_key] = asyncio.create_task(worker(app)) async def clean_up(app: web.Application) -> None: app[worker_key].cancel() with suppress(asyncio.CancelledError): await app[worker_key] async def on_shutdown(app: web.Application) -> None: waiters = [] for stream in app[streams_key]: stream.stop_streaming() waiters.append(stream.wait()) await asyncio.gather(*waiters, return_exceptions=True) app[streams_key].clear() async def hello(request: web.Request) -> web.StreamResponse: async with sse_response(request, response_cls=SSEResponse) as stream: request.app[streams_key].add(stream) try: await stream.wait() finally: request.app[streams_key].discard(stream) return stream async def index(_request: web.Request) -> web.StreamResponse: d = """

Response from server:


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


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

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

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

from aiohttp import web

from aiohttp_sse import sse_response


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


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

Response from server:

""" return web.Response(text=html, content_type="text/html") if __name__ == "__main__": app = web.Application() app.router.add_route("GET", "/hello", hello) app.router.add_route("GET", "/", index) web.run_app(app, host="127.0.0.1", port=8080) aio-libs-aiohttp-sse-6f63ee2/pyproject.toml000066400000000000000000000006411526301213700210060ustar00rootroot00000000000000[build-system] requires = ["setuptools>=82.0.1", "wheel>=0.47.0"] build-backend = "setuptools.build_meta" [tool.black] exclude = ''' /( \.git | venv | __pycache__ | \.tox )/ ''' [tool.towncrier] package = "aiohttp_sse" filename = "CHANGES.rst" directory = "CHANGES/" title_format = "{version} ({project_date})" issue_format = "`#{issue} `_" aio-libs-aiohttp-sse-6f63ee2/pytest.ini000066400000000000000000000006101526301213700201170ustar00rootroot00000000000000[pytest] addopts = # show 10 slowest invocations: --durations=10 # a bit of verbosity doesn't hurt: -v # report all the things == -rxXs: -ra # show values of the local vars in errors: --showlocals # coverage reports --cov=aiohttp_sse/ --cov=tests/ --cov-report term asyncio_mode = auto filterwarnings = error testpaths = tests/ xfail_strict = true aio-libs-aiohttp-sse-6f63ee2/requirements-dev.txt000066400000000000000000000000641526301213700221310ustar00rootroot00000000000000-r requirements.txt mypy==1.19.1 pre-commit==4.6.0 aio-libs-aiohttp-sse-6f63ee2/requirements.txt000066400000000000000000000001411526301213700213510ustar00rootroot00000000000000-e . aiohttp==3.14.4 pytest==9.1.1 pytest-aiohttp==1.1.1 pytest-asyncio==1.4.0 pytest-cov==7.1.0 aio-libs-aiohttp-sse-6f63ee2/setup.cfg000066400000000000000000000002741526301213700177150ustar00rootroot00000000000000[flake8] # TODO: don't disable D*, fix up issues instead ignore = N801,N802,N803,E203,E226,E305,W504,E252,E301,E302,E704,W503,W504,F811,D1,D4 max-line-length = 88 [isort] profile = black aio-libs-aiohttp-sse-6f63ee2/setup.py000066400000000000000000000035201526301213700176030ustar00rootroot00000000000000import ast import codecs import os from setuptools import find_packages, setup def read(f): with codecs.open( os.path.join(os.path.dirname(__file__), f), encoding="utf-8" ) as ofile: return ofile.read() class VersionFinder(ast.NodeVisitor): def __init__(self): self.version = None def visit_Assign(self, node): if not self.version: if node.targets[0].id == "__version__": self.version = node.value.value def read_version(): init_py = os.path.join(os.path.dirname(__file__), "aiohttp_sse", "__init__.py") finder = VersionFinder() finder.visit(ast.parse(read(init_py))) if finder.version is None: msg = "Cannot find version in aiohttp_sse/__init__.py" raise RuntimeError(msg) return finder.version install_requires = ["aiohttp>=3.0"] setup( name="aiohttp-sse", version=read_version(), description=("Server-sent events support for aiohttp."), long_description=read("README.rst"), classifiers=[ "License :: OSI Approved :: Apache Software License", "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Topic :: Internet :: WWW/HTTP", "Framework :: AsyncIO", "Framework :: aiohttp", ], author="Nikolay Novik", author_email="nickolainovik@gmail.com", url="https://github.com/aio-libs/aiohttp_sse/", license="Apache 2", python_requires=">=3.11", packages=find_packages(), install_requires=install_requires, include_package_data=True, ) aio-libs-aiohttp-sse-6f63ee2/tests/000077500000000000000000000000001526301213700172335ustar00rootroot00000000000000aio-libs-aiohttp-sse-6f63ee2/tests/conftest.py000066400000000000000000000010061526301213700214270ustar00rootroot00000000000000from asyncio import AbstractEventLoop, get_running_loop from collections.abc import AsyncIterator from typing import cast import pytest @pytest.fixture( scope="session", params=[True, False], ids=["debug:true", "debug:false"], ) def debug(request: pytest.FixtureRequest) -> bool: return cast(bool, request.param) @pytest.fixture(autouse=True) async def loop(debug: bool) -> AsyncIterator[AbstractEventLoop]: event_loop = get_running_loop() event_loop.set_debug(debug) yield event_loop aio-libs-aiohttp-sse-6f63ee2/tests/test_sse.py000066400000000000000000000677071526301213700214570ustar00rootroot00000000000000import asyncio import socket as sock_mod import tempfile import aiohttp import pytest from aiohttp import web from aiohttp.pytest_plugin import AiohttpClient from aiohttp.test_utils import make_mocked_request from aiohttp_sse import EventSourceResponse, sse_response socket = web.AppKey("socket", list[EventSourceResponse]) @pytest.mark.parametrize( "with_sse_response", (False, True), ids=("without_sse_response", "with_sse_response"), ) async def test_func(with_sse_response: bool, aiohttp_client: AiohttpClient) -> None: async def send_all(resp: EventSourceResponse) -> None: await resp.send("foo") await resp.send("foo", event="bar") await resp.send("foo", event="bar", id="xyz") await resp.send("foo", event="bar", id="xyz", retry=1) async def func(request: web.Request) -> web.StreamResponse: if with_sse_response: async with sse_response(request, headers={"X-SSE": "aiohttp_sse"}) as resp: await send_all(resp) return resp resp = EventSourceResponse(headers={"X-SSE": "aiohttp_sse"}) await resp.prepare(request) await send_all(resp) resp.stop_streaming() await resp.wait() return resp app = web.Application() app.router.add_route("GET", "/", func) app.router.add_route("POST", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert 200 == resp.status # make sure that EventSourceResponse supports passing # custom headers assert resp.headers.get("X-SSE") == "aiohttp_sse" # make sure default headers set assert resp.headers.get("Content-Type") == "text/event-stream" assert resp.headers.get("Cache-Control") == "no-cache" assert resp.headers.get("Connection") == "keep-alive" assert resp.headers.get("X-Accel-Buffering") == "no" # check streamed data streamed_data = await resp.text() expected = ( "data: foo\r\n\r\n" "event: bar\r\ndata: foo\r\n\r\n" "id: xyz\r\nevent: bar\r\ndata: foo\r\n\r\n" "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" ) assert streamed_data == expected async def test_wait_stop_streaming(aiohttp_client: AiohttpClient) -> None: async def func(request: web.Request) -> web.StreamResponse: app = request.app resp = EventSourceResponse() await resp.prepare(request) await resp.send("foo", event="bar", id="xyz", retry=1) app[socket].append(resp) await resp.wait() return resp app = web.Application() app[socket] = [] app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp_task = asyncio.create_task(client.get("/")) await asyncio.sleep(0.1) esourse = app[socket][0] esourse.stop_streaming() await esourse.wait() resp = await resp_task assert 200 == resp.status streamed_data = await resp.text() expected = "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" assert streamed_data == expected async def test_retry(aiohttp_client: AiohttpClient) -> None: async def func(request: web.Request) -> web.StreamResponse: resp = EventSourceResponse() await resp.prepare(request) with pytest.raises(ValueError, match="retry must not be negative"): await resp.send("foo", retry=-1) await resp.send("foo", retry=1) resp.stop_streaming() await resp.wait() return resp app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert 200 == resp.status # check streamed data streamed_data = await resp.text() expected = "data: foo\r\nretry: 1\r\n\r\n" assert streamed_data == expected async def test_wait_stop_streaming_errors() -> None: response = EventSourceResponse() with pytest.raises(RuntimeError) as ctx: await response.wait() assert str(ctx.value) == "Response is not started" with pytest.raises(RuntimeError) as ctx: response.stop_streaming() assert str(ctx.value) == "Response is not started" def test_compression_not_implemented() -> None: response = EventSourceResponse() with pytest.raises(NotImplementedError): response.enable_compression() class TestPingInterval: @pytest.mark.parametrize("value", (25, 25.0), ids=("int", "float")) def test_constructor(self, value: float) -> None: response = EventSourceResponse(ping_interval=value) assert response._ping_interval == value def test_default_value(self) -> None: response = EventSourceResponse() assert response._ping_interval == response.DEFAULT_PING_INTERVAL @pytest.mark.parametrize("value", (0, -42), ids=("zero", "negative")) def test_not_positive(self, value: float) -> None: with pytest.raises(ValueError, match="ping interval must be positive"): EventSourceResponse(ping_interval=value) @pytest.mark.parametrize("invalid_id", ("a\nb", "a\rb", "a\r\nb", "a\0b")) async def test_send_rejects_invalid_id(invalid_id: str) -> None: response = EventSourceResponse() with pytest.raises( ValueError, match="id must not contain line separators or null characters" ): await response.send("foo", id=invalid_id) @pytest.mark.parametrize("invalid_event", ("a\nb", "a\rb", "a\r\nb")) async def test_send_rejects_invalid_event(invalid_event: str) -> None: response = EventSourceResponse() with pytest.raises(ValueError, match="event must not contain line separators"): await response.send("foo", event=invalid_event) async def test_ping(aiohttp_client: AiohttpClient) -> None: async def func(request: web.Request) -> web.StreamResponse: app = request.app resp = EventSourceResponse(ping_interval=1) await resp.prepare(request) await resp.send("foo") app[socket].append(resp) await resp.wait() return resp app = web.Application() app[socket] = [] app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp_task = asyncio.create_task(client.get("/")) await asyncio.sleep(1.15) esourse = app[socket][0] esourse.stop_streaming() await esourse.wait() resp = await resp_task assert 200 == resp.status streamed_data = await resp.text() expected = "data: foo\r\n\r\n" + ": ping\r\n\r\n" assert streamed_data == expected async def test_ping_reset( aiohttp_client: AiohttpClient, monkeypatch: pytest.MonkeyPatch, ) -> None: async def func(request: web.Request) -> web.StreamResponse: app = request.app resp = EventSourceResponse(ping_interval=1) await resp.prepare(request) await resp.send("foo") app[socket].append(resp) await resp.wait() return resp app = web.Application() app[socket] = [] app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp_task = asyncio.create_task(client.get("/")) await asyncio.sleep(1.15) esource = app[socket][0] def reset_error_write(data: str) -> None: raise ConnectionResetError("Cannot write to closing transport") assert esource._ping_task assert not esource._ping_task.done() monkeypatch.setattr(esource, "write", reset_error_write) await esource.wait() assert esource._ping_task.done() resp = await resp_task assert 200 == resp.status streamed_data = await resp.text() expected = "data: foo\r\n\r\n" + ": ping\r\n\r\n" assert streamed_data == expected async def test_ping_auto_close(aiohttp_client: AiohttpClient) -> None: """Test ping task automatically closed on send failure.""" async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request, ping_interval=999) as sse: request.protocol.force_close() with pytest.raises(ConnectionResetError): await sse.send("never-should-be-delivered") assert sse._ping_task is not None assert sse._ping_task.cancelled() return sse # pragma: no cover app = web.Application() app.router.add_route("GET", "/", handler) client = await aiohttp_client(app) async with client.get("/") as response: assert 200 == response.status async def test_context_manager(aiohttp_client: AiohttpClient) -> None: async def func(request: web.Request) -> web.StreamResponse: h = {"X-SSE": "aiohttp_sse"} async with sse_response(request, headers=h) as sse: await sse.send("foo") await sse.send("foo", event="bar") await sse.send("foo", event="bar", id="xyz") await sse.send("foo", event="bar", id="xyz", retry=1) return sse app = web.Application() app.router.add_route("GET", "/", func) app.router.add_route("POST", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert resp.status == 200 # make sure that EventSourceResponse supports passing # custom headers assert resp.headers["X-SSE"] == "aiohttp_sse" # check streamed data streamed_data = await resp.text() expected = ( "data: foo\r\n\r\n" "event: bar\r\ndata: foo\r\n\r\n" "id: xyz\r\nevent: bar\r\ndata: foo\r\n\r\n" "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n" ) assert streamed_data == expected class TestCustomResponseClass: async def test_subclass(self) -> None: class CustomEventSource(EventSourceResponse): pass request = make_mocked_request("GET", "/") async with sse_response(request, response_cls=CustomEventSource) as resp: assert isinstance(resp, CustomEventSource) async def test_not_related_class(self) -> None: class CustomClass: pass request = make_mocked_request("GET", "/") with pytest.raises(TypeError): sse_response( request=request, response_cls=CustomClass, # type: ignore[type-var] ) @pytest.mark.parametrize("sep", ["\n", "\r", "\r\n"], ids=("LF", "CR", "CR+LF")) async def test_custom_sep(aiohttp_client: AiohttpClient, sep: str) -> None: async def func(request: web.Request) -> web.StreamResponse: h = {"X-SSE": "aiohttp_sse"} async with sse_response(request, headers=h, sep=sep) as sse: await sse.send("foo") await sse.send("foo", event="bar") await sse.send("foo", event="bar", id="xyz") await sse.send("foo", event="bar", id="xyz", retry=1) return sse app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert resp.status == 200 # make sure that EventSourceResponse supports passing # custom headers assert resp.headers["X-SSE"] == "aiohttp_sse" # check streamed data streamed_data = await resp.text() expected = ( "data: foo{0}{0}" "event: bar{0}data: foo{0}{0}" "id: xyz{0}event: bar{0}data: foo{0}{0}" "id: xyz{0}event: bar{0}data: foo{0}retry: 1{0}{0}" ) assert streamed_data == expected.format(sep) @pytest.mark.parametrize( "stream_sep,line_sep", [ ( "\n", "\n", ), ( "\n", "\r", ), ( "\n", "\r\n", ), ( "\r", "\n", ), ( "\r", "\r", ), ( "\r", "\r\n", ), ( "\r\n", "\n", ), ( "\r\n", "\r", ), ( "\r\n", "\r\n", ), ], ids=( "steam-LF:line-LF", "steam-LF:line-CR", "steam-LF:line-CR+LF", "steam-CR:line-LF", "steam-CR:line-CR", "steam-CR:line-CR+LF", "steam-CR+LF:line-LF", "steam-CR+LF:line-CR", "steam-CR+LF:line-CR+LF", ), ) async def test_multiline_data( aiohttp_client: AiohttpClient, stream_sep: str, line_sep: str, ) -> None: async def func(request: web.Request) -> web.StreamResponse: h = {"X-SSE": "aiohttp_sse"} lines = line_sep.join(["foo", "bar", "xyz"]) async with sse_response(request, headers=h, sep=stream_sep) as sse: await sse.send(lines) await sse.send(lines, event="bar") await sse.send(lines, event="bar", id="xyz") await sse.send(lines, event="bar", id="xyz", retry=1) return sse app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert resp.status == 200 # make sure that EventSourceResponse supports passing # custom headers assert resp.headers["X-SSE"] == "aiohttp_sse" # check streamed data streamed_data = await resp.text() expected = ( "data: foo{0}data: bar{0}data: xyz{0}{0}" "event: bar{0}data: foo{0}data: bar{0}data: xyz{0}{0}" "id: xyz{0}event: bar{0}data: foo{0}data: bar{0}data: xyz{0}{0}" "id: xyz{0}event: bar{0}data: foo{0}data: bar{0}data: xyz{0}" "retry: 1{0}{0}" ) assert streamed_data == expected.format(stream_sep) class TestSSEState: async def test_context_states(self, aiohttp_client: AiohttpClient) -> None: async def func(request: web.Request) -> web.StreamResponse: async with sse_response(request) as resp: assert resp.is_connected() assert not resp.is_connected() return resp app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) resp = await client.get("/") assert resp.status == 200 async def test_not_prepared(self) -> None: response = EventSourceResponse() assert not response.is_connected() async def test_connection_is_not_alive(aiohttp_client: AiohttpClient) -> None: async def func(request: web.Request) -> web.StreamResponse: # within context manager first preparation is already done async with sse_response(request) as sse: request.protocol.force_close() # this call should be cancelled, cause connection is closed with pytest.raises(asyncio.CancelledError): await sse.prepare(request) return sse # pragma: no cover app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) async with client.get("/") as resp: assert resp.status == 200 class TestLastEventId: async def test_success(self, aiohttp_client: AiohttpClient) -> None: async def func(request: web.Request) -> web.StreamResponse: async with sse_response(request) as sse: assert sse.last_event_id is not None await sse.send(sse.last_event_id) return sse app = web.Application() app.router.add_route("GET", "/", func) client = await aiohttp_client(app) async with client.get("/") as resp: assert resp.status == 200 last_event_id = "42" headers = {EventSourceResponse.DEFAULT_LAST_EVENT_HEADER: last_event_id} async with client.get("/", headers=headers) as resp: assert resp.status == 200 # check streamed data streamed_data = await resp.text() assert streamed_data == f"data: {last_event_id}\r\n\r\n" async def test_get_before_prepare(self) -> None: sse = EventSourceResponse() with pytest.raises(RuntimeError): _ = sse.last_event_id @pytest.mark.parametrize( "http_method", ("GET", "POST", "PUT", "DELETE", "PATCH"), ) async def test_http_methods(aiohttp_client: AiohttpClient, http_method: str) -> None: async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request, allow_all_methods=True) as sse: await sse.send("foo") return sse app = web.Application() app.router.add_route(http_method, "/", handler) client = await aiohttp_client(app) async with client.request(http_method, "/") as resp: assert resp.status == 200 # check streamed data streamed_data = await resp.text() assert streamed_data == "data: foo\r\n\r\n" @pytest.mark.parametrize( "http_method", ("POST", "PUT", "DELETE", "PATCH"), ) async def test_not_allowed_methods( aiohttp_client: AiohttpClient, http_method: str, ) -> None: """Check that EventSourceResponse works only with GET method.""" async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request): assert False app = web.Application() app.router.add_route(http_method, "/", handler) client = await aiohttp_client(app) async with client.request(http_method, "/") as resp: assert resp.status == 405 async def test_cancelled_not_swallowed(aiohttp_client: AiohttpClient) -> None: """Test asyncio.CancelledError is not swallowed by .wait(). Relates to: https://github.com/aio-libs/aiohttp-sse/issues/458 """ async def endless_task(sse: EventSourceResponse) -> None: while True: await sse.wait() async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request) as sse: task = asyncio.create_task(endless_task(sse)) await asyncio.sleep(0) task.cancel() await task return sse # pragma: no cover app = web.Application() app.router.add_route("GET", "/", handler) client = await aiohttp_client(app) async with client.get("/") as response: assert 200 == response.status @pytest.mark.parametrize("timeout", (None, 0.1)) async def test_with_timeout( aiohttp_client: AiohttpClient, timeout: float | None, ) -> None: """Test that a timeout occurs when client is not reading responses.""" timeout_raised = False should_raise_timeout = timeout is not None async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request, send_timeout=timeout) as sse: while True: # .send() only yields if socket is full, so yield here to run client. await asyncio.sleep(0) try: await sse.send("x" * 10000000) # Enough data to fill socket except TimeoutError: nonlocal timeout_raised timeout_raised = True break assert False app = web.Application() app.router.add_route("GET", "/", handler) client = await aiohttp_client(app) async with client.get("/") as resp: assert resp.status == 200 await asyncio.sleep(0.5) assert timeout_raised is should_raise_timeout async def test_ping_timeout(aiohttp_client: AiohttpClient) -> None: """Test that a ping write timeout aborts a stalled connection.""" async def handler(request: web.Request) -> EventSourceResponse: # Huge separator makes the ping message itself fill the socket. sep = "\r\n" + " " * 10_000_000 async with sse_response( request, sep=sep, send_timeout=0.1, ping_interval=0.01 ) as sse: # Returns once the timed-out ping stops the stream. The test # server then cancels the handler (handler_cancellation=True), # so nothing after this await is reachable under test. await sse.wait() assert False app = web.Application() app.router.add_route("GET", "/", handler) client = await aiohttp_client(app) async with client.get("/") as resp: assert resp.status == 200 await asyncio.sleep(0.5) # Let the server stall on a ping and time out. # The server must have aborted the connection; otherwise reading # would stream pings forever (bounded here by the timeout below). with pytest.raises(aiohttp.ClientPayloadError): async with asyncio.timeout(5): await resp.content.read(-1) async def test_abort_when_transport_already_gone() -> None: """A timeout can race a disconnect; cleanup must cope without a transport. Uses a plain AppRunner because TestServer enables handler_cancellation, which would cancel the handler at the moment of disconnect. """ aborted = asyncio.Event() async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request, send_timeout=10) as sse: # Wait for connection_lost to clear the transport. while request.protocol.transport is not None: await asyncio.sleep(0.01) # Same state as a timeout firing in the window where the # peer already disconnected: no transport left to abort. sse._abort_transport() # Restore is equally safe once the socket is closed. sse._restore_tcp_user_timeout() aborted.set() return sse app = web.Application() app.router.add_route("GET", "/", handler) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "127.0.0.1", 0) await site.start() port = runner.addresses[0][1] try: reader, writer = await asyncio.open_connection("127.0.0.1", port) writer.write(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") await writer.drain() await reader.readuntil(b"\r\n\r\n") # Response headers received. writer.close() await writer.wait_closed() async with asyncio.timeout(5): await aborted.wait() finally: await runner.cleanup() @pytest.mark.skipif( not hasattr(sock_mod, "TCP_USER_TIMEOUT"), reason="TCP_USER_TIMEOUT is Linux-only", ) async def test_tcp_user_timeout_set(aiohttp_client: AiohttpClient) -> None: """send_timeout is mirrored into TCP_USER_TIMEOUT for kernel detection. The asyncio-level timeout only fires once enough data queues to block on flow control; TCP_USER_TIMEOUT detects a hung peer as soon as any write (e.g. a ping) goes unacknowledged for the same duration. """ value = None async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request, send_timeout=2.5) as sse: assert request.transport is not None sock = request.transport.get_extra_info("socket") nonlocal value value = sock.getsockopt(sock_mod.IPPROTO_TCP, sock_mod.TCP_USER_TIMEOUT) return sse app = web.Application() app.router.add_route("GET", "/", handler) client = await aiohttp_client(app) async with client.get("/") as resp: assert resp.status == 200 assert value == 2500 @pytest.mark.skipif( not hasattr(sock_mod, "TCP_USER_TIMEOUT"), reason="TCP_USER_TIMEOUT is Linux-only", ) async def test_tcp_user_timeout_aborts_hung_client() -> None: """The kernel aborts a hung connection long before buffers fill. The client's receive window is shrunk so it exhausts after a few KB, while the server writes less than aiohttp's 64KiB flow-control limit: send() returns immediately and no asyncio timer is ever pending, so only TCP_USER_TIMEOUT (zero-window handling, Linux 5.1+) can detect the hung peer. Without the sockopt this test times out. Uses a plain AppRunner so the handler survives the disconnect (TestServer enables handler_cancellation). """ loop = asyncio.get_running_loop() woke = asyncio.Event() async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request, send_timeout=1, ping_interval=0.1) as sse: # Over the client's tiny receive window, under the 64KiB # high-water mark, so send() does not block on flow control. await sse.send("x" * 48_000) # Woken by the ping task ending when the kernel aborts. await sse.wait() woke.set() return sse app = web.Application() app.router.add_route("GET", "/", handler) runner = web.AppRunner(app, shutdown_timeout=1) await runner.setup() site = web.TCPSite(runner, "127.0.0.1", 0) await site.start() port = runner.addresses[0][1] sock = sock_mod.socket(sock_mod.AF_INET, sock_mod.SOCK_STREAM) try: # Must be set before connect to cap the advertised window. # By reducing the client buffer, we can ensure the stall appears due to # the client not accepting any more data once the buffer is full. sock.setsockopt(sock_mod.SOL_SOCKET, sock_mod.SO_RCVBUF, 4096) sock.setblocking(False) await loop.sock_connect(sock, ("127.0.0.1", port)) await loop.sock_sendall(sock, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") await loop.sock_recv(sock, 1024) # Headers; then never read again. # send_timeout=1 plus one retransmission backoff; generous margin. async with asyncio.timeout(5): await woke.wait() finally: sock.close() await runner.cleanup() @pytest.mark.skipif( not hasattr(sock_mod, "AF_UNIX"), reason="Unix sockets unavailable", ) async def test_unix_socket() -> None: """SSE over a Unix socket works: TCP_USER_TIMEOUT must not be applied. Not using the tmp_path fixture: sun_path is limited to ~104 bytes on macOS and pytest's basetemp exceeds it. """ async def handler(request: web.Request) -> EventSourceResponse: async with sse_response(request) as sse: # Default send_timeout. await sse.send("hi") return sse app = web.Application() app.router.add_route("GET", "/", handler) runner = web.AppRunner(app, shutdown_timeout=1) await runner.setup() with tempfile.TemporaryDirectory(dir="/tmp") as tmp_dir: path = f"{tmp_dir}/sse.sock" await web.UnixSite(runner, path).start() try: connector = aiohttp.UnixConnector(path=path) async with aiohttp.ClientSession(connector=connector) as session: async with session.get("http://localhost/") as resp: assert resp.status == 200 assert await resp.text() == "data: hi\r\n\r\n" finally: await runner.cleanup() @pytest.mark.skipif( not hasattr(sock_mod, "TCP_USER_TIMEOUT"), reason="TCP_USER_TIMEOUT is Linux-only", ) async def test_tcp_user_timeout_restored_on_keepalive_reuse( aiohttp_client: AiohttpClient, ) -> None: seen = [] def record(request: web.Request) -> None: assert request.transport is not None sock = request.transport.get_extra_info("socket") seen.append( ( sock.getpeername(), sock.getsockopt(sock_mod.IPPROTO_TCP, sock_mod.TCP_USER_TIMEOUT), ) ) async def sse_handler(request: web.Request) -> EventSourceResponse: assert request.transport is not None # Simulate a value inherited from the listening socket. request.transport.get_extra_info("socket").setsockopt( sock_mod.IPPROTO_TCP, sock_mod.TCP_USER_TIMEOUT, 7000 ) async with sse_response(request, send_timeout=1) as sse: record(request) await sse.send("x") return sse async def plain_handler(request: web.Request) -> web.Response: record(request) return web.Response(text="ok") app = web.Application() app.router.add_route("GET", "/first", sse_handler) app.router.add_route("GET", "/second", plain_handler) client = await aiohttp_client(app) async with client.get("/first") as resp: await resp.text() async with client.get("/second") as resp: await resp.text() (peer1, timeout1), (peer2, timeout2) = seen assert peer1 == peer2 # Same connection, or the test proves nothing. assert timeout1 == 1000 # Active during the SSE stream. assert timeout2 == 7000 # Original value restored for the next request.