├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── references.rst ├── index.rst ├── usage.rst ├── Makefile ├── make.bat ├── installation.rst └── conf.py ├── tests ├── conftest.py ├── web_platform_tests │ ├── const.py │ ├── __init__.py │ ├── test_eventsource_onmessage.py │ ├── test_eventsource_onopen.py │ ├── test_eventsource_request_cancellation.py │ ├── test_eventsource_reconnect.py │ ├── test_eventsource_close.py │ ├── test_event_data.py │ ├── test_format_field_id.py │ └── test_request.py └── test_client.py ├── aiohttp_sse_client ├── __init__.py └── client.py ├── requirements_dev.txt ├── AUTHORS.rst ├── MANIFEST.in ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── pullrequest.yaml ├── setup.cfg ├── LICENSE ├── tox.ini ├── .gitignore ├── HISTORY.rst ├── setup.py ├── README.rst ├── Makefile └── CONTRIBUTING.rst /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.DEBUG) 4 | -------------------------------------------------------------------------------- /tests/web_platform_tests/const.py: -------------------------------------------------------------------------------- 1 | WPT_SERVER = 'http://www.w3c-test.org/eventsource/' 2 | -------------------------------------------------------------------------------- /tests/web_platform_tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests against web-platform-tests eventsource test suite. 2 | 3 | ..seealso: https://github.com/web-platform-tests/wpt/tree/master/eventsource 4 | """ 5 | -------------------------------------------------------------------------------- /aiohttp_sse_client/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Top-level package for SSE Client.""" 4 | 5 | __author__ = """Jason Hu""" 6 | __email__ = 'awaregit@gmail.com' 7 | __version__ = '0.2.1' 8 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==21.1.1 2 | bump2version==1.0.1 3 | wheel==0.36.2 4 | watchdog==2.0.2 5 | flake8==3.9.0 6 | tox==3.23.0 7 | coverage==5.5 8 | Sphinx==3.5.3 9 | twine==3.4.1 10 | 11 | pytest==6.2.2 12 | pytest-aiohttp==0.3.0 13 | pytest-runner==5.3.0 14 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Jason Hu 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Ron Serruya @Ronserruya 14 | * tjstub @tjstub 15 | * Pavel Filatov @paulefoe 16 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /docs/references.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | References 3 | ========== 4 | 5 | Class EventSource 6 | ----------------- 7 | 8 | .. autoclass:: aiohttp_sse_client.client.EventSource 9 | :members: 10 | 11 | .. automethod:: __init__ 12 | 13 | Class MessageEvent 14 | ------------------ 15 | 16 | .. autoclass:: aiohttp_sse_client.client.MessageEvent 17 | :members: 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Python version: 2 | * Operating System: 3 | * aiohttp version: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to SSE Client's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | references 12 | modules 13 | contributing 14 | authors 15 | history 16 | 17 | Indices and tables 18 | ================== 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:aiohttp_sse_client/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | [aliases] 21 | test = pytest 22 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use SSE Client in a project: 6 | 7 | .. code-block:: python 8 | 9 | from aiohttp_sse_client import client as sse_client 10 | 11 | async with sse_client.EventSource( 12 | 'https://stream.wikimedia.org/v2/stream/recentchange' 13 | ) as event_source: 14 | try: 15 | async for event in event_source: 16 | print(event) 17 | except ConnectionError: 18 | pass 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache Software License 2.0 2 | 3 | Copyright (c) 2018, Jason Hu 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = aiohttp_sse_client 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, flake8 3 | 4 | [travis] 5 | python = 6 | 3.8: py38 7 | 3.7: py37 8 | 3.6: py36 9 | 10 | [testenv:flake8] 11 | basepython = python 12 | deps = flake8 13 | commands = flake8 aiohttp_sse_client 14 | 15 | [testenv] 16 | setenv = 17 | PYTHONPATH = {toxinidir} 18 | deps = 19 | -r{toxinidir}/requirements_dev.txt 20 | ; If you want to make tox run the tests with the same versions, create a 21 | ; requirements.txt with the pinned versions and uncomment the following line: 22 | ; -r{toxinidir}/requirements.txt 23 | commands = 24 | pip install -U pip 25 | py.test --basetemp={envtmpdir} 26 | 27 | [testenv:py36] 28 | deps = 29 | cryptography>=3,<3.4 30 | -r{toxinidir}/requirements_dev.txt 31 | -------------------------------------------------------------------------------- /tests/web_platform_tests/test_eventsource_onmessage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime, timedelta 4 | 5 | from aiohttp_sse_client import client as sse_client 6 | 7 | from .const import WPT_SERVER 8 | 9 | 10 | async def test_eventsource_onmessage(): 11 | """Test EventSource: onmessage. 12 | 13 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 14 | eventsource/eventsource-onmessage.htm 15 | """ 16 | def on_message(event): 17 | """Callback for message event.""" 18 | assert event.data == "data" 19 | 20 | source = sse_client.EventSource(WPT_SERVER + 'resources/message.py', 21 | on_message=on_message) 22 | await source.connect() 23 | async for e in source: 24 | assert e.data == "data" 25 | break 26 | await source.close() 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=aiohttp_sse_client 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /tests/web_platform_tests/test_eventsource_onopen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime, timedelta 4 | 5 | from aiohttp_sse_client import client as sse_client 6 | 7 | from .const import WPT_SERVER 8 | 9 | 10 | async def test_eventsource_onopen(): 11 | """Test EventSource: open (announcing the connection). 12 | 13 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 14 | eventsource/eventsource-onopen.htm 15 | """ 16 | def on_open(): 17 | """Callback for open event.""" 18 | assert source.ready_state == sse_client.READY_STATE_OPEN 19 | 20 | source = sse_client.EventSource(WPT_SERVER + 'resources/message.py', 21 | on_open=on_open) 22 | assert source.ready_state == sse_client.READY_STATE_CONNECTING 23 | await source.connect() 24 | assert source.ready_state == sse_client.READY_STATE_OPEN 25 | await source.close() 26 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Tests for `aiohttp_sse_client` package.""" 4 | import json 5 | 6 | from aiohttp_sse_client import client as sse_client 7 | 8 | 9 | async def test_basic_usage(): 10 | """Test basic usage.""" 11 | messages = [] 12 | async with sse_client.EventSource( 13 | 'https://stream.wikimedia.org/v2/stream/recentchange' 14 | ) as event_source: 15 | async for message in event_source: 16 | if len(messages) > 1: 17 | break 18 | messages.append(message) 19 | 20 | print(messages) 21 | assert messages[0].type == 'message' 22 | assert messages[0].origin == 'https://stream.wikimedia.org' 23 | assert messages[1].type == 'message' 24 | assert messages[1].origin == 'https://stream.wikimedia.org' 25 | data_0 = json.loads(messages[0].data) 26 | data_1 = json.loads(messages[1].data) 27 | assert data_0['meta']['id'] != data_1['meta']['id'] 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # Installer logs 29 | pip-log.txt 30 | pip-delete-this-directory.txt 31 | 32 | # Unit test / coverage reports 33 | htmlcov/ 34 | .tox/ 35 | .coverage 36 | .coverage.* 37 | .cache 38 | nosetests.xml 39 | coverage.xml 40 | *.cover 41 | .hypothesis/ 42 | .pytest_cache/ 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Sphinx documentation 49 | docs/_build/ 50 | 51 | # pyenv 52 | .python-version 53 | 54 | # dotenv 55 | .env 56 | 57 | # virtualenv 58 | .venv 59 | venv/ 60 | ENV/ 61 | 62 | # mkdocs documentation 63 | /site 64 | 65 | # mypy 66 | .mypy_cache/ 67 | 68 | # IDE 69 | .idea 70 | .vscode 71 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.2.1 (2021-02-27) 6 | ------------------ 7 | 8 | * Allow sending request with different HTTP methods (by @paulefoe) 9 | * Migrate to GitHub Actions 10 | 11 | 0.2.0 (2020-10-20) 12 | ------------------ 13 | 14 | **Breaking Changes** 15 | 16 | * Drop Python 3.5 support 17 | * Add Python 3.8 support 18 | 19 | **Non functional changes** 20 | 21 | * Clarify the license (Apache Software License 2.0), thanks @fabaff 22 | * Update dependency packages 23 | 24 | 25 | 0.1.7 (2020-03-30) 26 | ------------------ 27 | 28 | * Allow passing kwargs without specifying headers 29 | 30 | 0.1.6 (2019-08-06) 31 | ------------------ 32 | 33 | * Fix Unicode NULL handling in event id field 34 | 35 | 0.1.5 (2019-08-06) 36 | ------------------ 37 | 38 | * Fix last id reconnection (by @Ronserruya) 39 | 40 | 0.1.4 (2018-10-04) 41 | ------------------ 42 | 43 | * Switch to Apache Software License 2.0 44 | 45 | 0.1.3 (2018-10-03) 46 | ------------------ 47 | 48 | * Change the error handling, better fit the live specification. 49 | 50 | 0.1.2 (2018-10-03) 51 | ------------------ 52 | 53 | * Implement auto-reconnect feature. 54 | 55 | 0.1.1 (2018-10-02) 56 | ------------------ 57 | 58 | * First release on PyPI. 59 | -------------------------------------------------------------------------------- /tests/web_platform_tests/test_eventsource_request_cancellation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import asyncio 4 | from datetime import datetime, timedelta 5 | 6 | from aiohttp_sse_client import client as sse_client 7 | 8 | from .const import WPT_SERVER 9 | 10 | 11 | async def test_eventsource_request_cancellation(): 12 | """Test EventSource: reconnection event. 13 | 14 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 15 | eventsource/eventsource-request-cancellation.htm 16 | """ 17 | closed = False 18 | def on_open(): 19 | if closed: 20 | assert False 21 | 22 | def on_error(): 23 | assert source.ready_state == sse_client.READY_STATE_CLOSED 24 | 25 | try: 26 | async with sse_client.EventSource( 27 | WPT_SERVER + 'resources/message.py?sleep=1000&message=' + 28 | "retry:1000\ndata:abc\n\n", 29 | on_open=on_open, 30 | on_error=on_error 31 | ) as source: 32 | raise ConnectionAbortedError 33 | except ConnectionAbortedError: 34 | closed = True 35 | await asyncio.sleep(1) 36 | assert source.ready_state == sse_client.READY_STATE_CLOSED 37 | pass 38 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install SSE Client, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install aiohttp-sse-client 16 | 17 | This is the preferred method to install SSE Client, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for SSE Client can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/rtfol/aiohttp-sse-client 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OL https://github.com/rtfol/aiohttp-sse-client/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/rtfol/aiohttp-sse-client 51 | .. _tarball: https://github.com/rtfol/aiohttp-sse-client/tarball/master 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """The setup script.""" 5 | 6 | from setuptools import setup, find_packages 7 | 8 | with open('README.rst') as readme_file: 9 | readme = readme_file.read() 10 | 11 | with open('HISTORY.rst') as history_file: 12 | history = history_file.read() 13 | 14 | requirements = ['aiohttp>=3', 'attrs', 'multidict', 'yarl', ] 15 | 16 | setup_requirements = ['pytest-runner', ] 17 | 18 | test_requirements = ['pytest', ] 19 | 20 | setup( 21 | author="Jason Hu", 22 | author_email='awaregit@gmail.com', 23 | classifiers=[ 24 | 'Development Status :: 3 - Alpha', 25 | 'Framework :: AsyncIO', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: Apache Software License', 28 | 'Natural Language :: English', 29 | 'Programming Language :: Python :: 3', 30 | 'Programming Language :: Python :: 3.6', 31 | 'Programming Language :: Python :: 3.7', 32 | 'Programming Language :: Python :: 3.8', 33 | 'Programming Language :: Python :: 3.9', 34 | 'Topic :: Internet :: WWW/HTTP', 35 | ], 36 | description="A Server-Sent Event python client base on aiohttp", 37 | install_requires=requirements, 38 | license="Apache License 2.0", 39 | long_description=readme + '\n\n' + history, 40 | include_package_data=True, 41 | keywords='aiohttp_sse_client', 42 | name='aiohttp-sse-client', 43 | packages=find_packages(include=['aiohttp_sse_client']), 44 | setup_requires=setup_requirements, 45 | test_suite='tests', 46 | tests_require=test_requirements, 47 | url='https://github.com/rtfol/aiohttp-sse-client', 48 | version='0.2.1', 49 | zip_safe=False, 50 | ) 51 | -------------------------------------------------------------------------------- /tests/web_platform_tests/test_eventsource_reconnect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime, timedelta 4 | 5 | from aiohttp_sse_client import client as sse_client 6 | 7 | from .const import WPT_SERVER 8 | 9 | 10 | async def test_eventsource_reconnect(): 11 | """Test EventSource: reconnection. 12 | 13 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 14 | eventsource/eventsource-reconnect.htm 15 | """ 16 | source = sse_client.EventSource( 17 | WPT_SERVER + 'resources/status-reconnect.py?status=200') 18 | await source.connect() 19 | async for e in source: 20 | assert e.data == 'data' 21 | break 22 | await source.close() 23 | 24 | 25 | async def test_eventsource_reconnect_event(): 26 | """Test EventSource: reconnection event. 27 | 28 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 29 | eventsource/eventsource-reconnect.htm 30 | """ 31 | opened = False 32 | reconnected = False 33 | 34 | def on_error(): 35 | nonlocal reconnected 36 | assert source.ready_state == sse_client.READY_STATE_CONNECTING 37 | assert opened is True 38 | reconnected = True 39 | 40 | async with sse_client.EventSource( 41 | WPT_SERVER + 'resources/status-reconnect.py?status=200&ok_first&id=2', 42 | reconnection_time=timedelta(milliseconds=2), 43 | on_error=on_error 44 | ) as source: 45 | async for e in source: 46 | if not opened: 47 | opened = True 48 | assert reconnected is False 49 | assert e.data == "ok" 50 | else: 51 | assert reconnected is True 52 | assert e.data == "data" 53 | break 54 | -------------------------------------------------------------------------------- /.github/workflows/pullrequest.yaml: -------------------------------------------------------------------------------- 1 | name: python 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.6, 3.7, 3.8, 3.9] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Cache pip 20 | uses: actions/cache@v2 21 | with: 22 | # This path is specific to Ubuntu 23 | path: ~/.cache/pip 24 | # Look to see if there is a cache hit for the corresponding requirements file 25 | key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('requirements_dev.txt') }}-${{ hashFiles('setup.py') }} 26 | restore-keys: | 27 | ${{ runner.os }}-${{ matrix.python-version }}-pip- 28 | ${{ runner.os }}-${{ matrix.python-version }}- 29 | - name: Install dev dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install -r requirements_dev.txt 33 | - name: Lint with flake8 34 | run: | 35 | flake8 aiohttp_sse_client 36 | - name: Install 37 | run: | 38 | pip install -e . 39 | - name: Test with pytest 40 | run: | 41 | pytest 42 | - name: Build wheel 43 | if: matrix.python-version == 3.6 44 | run: | 45 | python setup.py sdist bdist_wheel 46 | - name: Publish distribution 📦 to Test PyPI 47 | if: matrix.python-version == 3.6 && startsWith(github.ref, 'refs/tags') 48 | uses: pypa/gh-action-pypi-publish@master 49 | with: 50 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 51 | repository_url: https://test.pypi.org/legacy/ 52 | - name: Publish distribution 📦 to PyPI 53 | if: matrix.python-version == 3.6 && startsWith(github.ref, 'refs/tags') 54 | uses: pypa/gh-action-pypi-publish@master 55 | with: 56 | password: ${{ secrets.PYPI_API_TOKEN }} 57 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | SSE Client 3 | ========== 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/aiohttp_sse_client.svg 7 | :target: https://pypi.python.org/pypi/aiohttp_sse_client 8 | 9 | .. image:: https://img.shields.io/travis/com/rtfol/aiohttp-sse-client.svg 10 | :target: https://travis-ci.com/rtfol/aiohttp-sse-client 11 | 12 | .. image:: https://readthedocs.org/projects/aiohttp-sse-client/badge/?version=latest 13 | :target: https://aiohttp-sse-client.readthedocs.io/en/latest/?badge=latest 14 | :alt: Documentation Status 15 | 16 | .. image:: https://pyup.io/repos/github/rtfol/aiohttp-sse-client/shield.svg 17 | :target: https://pyup.io/repos/github/rtfol/aiohttp-sse-client/ 18 | :alt: Updates 19 | 20 | 21 | A Server-Sent Event python client base on aiohttp, provides a simple interface to process `Server-Sent Event `_. 22 | 23 | * Free software: Apache Software License 2.0 24 | * Documentation: https://aiohttp-sse-client.readthedocs.io. 25 | 26 | 27 | Features 28 | -------- 29 | 30 | * Full asyncio support 31 | * Easy to integrate with other aiohttp based project 32 | * Auto-reconnect for network issue 33 | * Support python 3.6 and above 34 | 35 | Usage 36 | -------- 37 | .. code-block:: python 38 | 39 | from aiohttp_sse_client import client as sse_client 40 | 41 | async with sse_client.EventSource( 42 | 'https://stream.wikimedia.org/v2/stream/recentchange' 43 | ) as event_source: 44 | try: 45 | async for event in event_source: 46 | print(event) 47 | except ConnectionError: 48 | pass 49 | 50 | Credits 51 | ------- 52 | 53 | This project was inspired by `aiosseclient `_, 54 | `sseclient `_, and `sseclient-py `_. 55 | 56 | This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. 57 | 58 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 59 | .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 60 | -------------------------------------------------------------------------------- /tests/web_platform_tests/test_eventsource_close.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime, timedelta 4 | 5 | from aiohttp_sse_client import client as sse_client 6 | 7 | from .const import WPT_SERVER 8 | 9 | 10 | async def test_eventsource_close(): 11 | """Test EventSource: close. 12 | 13 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 14 | eventsource/eventsource-close.htm 15 | """ 16 | source = sse_client.EventSource(WPT_SERVER + 'resources/message.py') 17 | assert source.ready_state == sse_client.READY_STATE_CONNECTING 18 | await source.connect() 19 | assert source.ready_state == sse_client.READY_STATE_OPEN 20 | await source.close() 21 | assert source.ready_state == sse_client.READY_STATE_CLOSED 22 | 23 | 24 | async def test_eventsource_close_reconnect(): 25 | """Test EventSource: close/reconnect. 26 | 27 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 28 | eventsource/eventsource-close.htm 29 | """ 30 | count = 0 31 | reconnected = False 32 | 33 | def on_error(): 34 | nonlocal count, reconnected 35 | if count == 1: 36 | assert source.ready_state == sse_client.READY_STATE_CONNECTING 37 | reconnected = True 38 | elif count == 2: 39 | assert source.ready_state == sse_client.READY_STATE_CONNECTING 40 | count += 1 41 | elif count == 3: 42 | assert source.ready_state == sse_client.READY_STATE_CLOSED 43 | else: 44 | assert False 45 | 46 | async with sse_client.EventSource( 47 | WPT_SERVER + 'resources/reconnect-fail.py?id=' + 48 | str(datetime.utcnow().timestamp()), 49 | reconnection_time=timedelta(milliseconds=2), 50 | on_error=on_error 51 | ) as source: 52 | try: 53 | async for e in source: 54 | if count == 0: 55 | assert reconnected is False 56 | assert e.data == "opened" 57 | elif count == 1: 58 | assert reconnected is True 59 | assert e.data == "reconnected" 60 | else: 61 | assert False 62 | count += 1 63 | except ConnectionError: 64 | pass 65 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -fr .pytest_cache 52 | 53 | lint: ## check style with flake8 54 | flake8 aiohttp_sse_client tests 55 | 56 | test: ## run tests quickly with the default Python 57 | py.test 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | coverage run --source aiohttp_sse_client -m pytest 64 | coverage report -m 65 | coverage html 66 | $(BROWSER) htmlcov/index.html 67 | 68 | docs: ## generate Sphinx HTML documentation, including API docs 69 | rm -f docs/aiohttp_sse_client.rst 70 | rm -f docs/modules.rst 71 | sphinx-apidoc -o docs/ aiohttp_sse_client 72 | $(MAKE) -C docs clean 73 | $(MAKE) -C docs html 74 | $(BROWSER) docs/_build/html/index.html 75 | 76 | servedocs: docs ## compile the docs watching for changes 77 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 78 | 79 | release: dist ## package and upload a release 80 | twine upload dist/* 81 | 82 | dist: clean ## builds source and wheel package 83 | python setup.py sdist 84 | python setup.py bdist_wheel 85 | ls -l dist 86 | 87 | install: clean ## install the package to the active Python's site-packages 88 | python setup.py install 89 | -------------------------------------------------------------------------------- /tests/web_platform_tests/test_event_data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime, timedelta 4 | 5 | from aiohttp_sse_client import client as sse_client 6 | 7 | from .const import WPT_SERVER 8 | 9 | async def test_event_data(): 10 | """Test EventSource: lines and data parsing. 11 | 12 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 13 | eventsource/event-data.html 14 | """ 15 | counter = 0 16 | source = sse_client.EventSource(WPT_SERVER + 'resources/message2.py') 17 | await source.connect() 18 | async for e in source: 19 | if counter == 0: 20 | assert e.data == "msg\nmsg" 21 | elif counter == 1: 22 | assert e.data == "" 23 | elif counter == 2: 24 | assert e.data == "end" 25 | await source.close() 26 | break 27 | else: 28 | assert False 29 | counter += 1 30 | 31 | 32 | async def test_eventsource_close(): 33 | """Test EventSource: close. 34 | 35 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 36 | eventsource/eventsource-close.htm 37 | """ 38 | source = sse_client.EventSource(WPT_SERVER + 'resources/message.py') 39 | assert source.ready_state == sse_client.READY_STATE_CONNECTING 40 | await source.connect() 41 | assert source.ready_state == sse_client.READY_STATE_OPEN 42 | await source.close() 43 | assert source.ready_state == sse_client.READY_STATE_CLOSED 44 | 45 | count = 0 46 | reconnected = False 47 | 48 | def on_error(): 49 | nonlocal count, reconnected 50 | if count == 1: 51 | assert source.ready_state == sse_client.READY_STATE_CONNECTING 52 | reconnected = True 53 | elif count == 2: 54 | assert source.ready_state == sse_client.READY_STATE_CONNECTING 55 | count += 1 56 | elif count == 3: 57 | assert source.ready_state == sse_client.READY_STATE_CLOSED 58 | else: 59 | assert False 60 | 61 | async with sse_client.EventSource( 62 | WPT_SERVER + 'resources/reconnect-fail.py?id=' + 63 | str(datetime.utcnow().timestamp()), 64 | reconnection_time=timedelta(milliseconds=2), 65 | on_error=on_error 66 | ) as source: 67 | try: 68 | async for e in source: 69 | if count == 0: 70 | assert reconnected is False 71 | assert e.data == "opened" 72 | elif count == 1: 73 | assert reconnected is True 74 | assert e.data == "reconnected" 75 | else: 76 | assert False 77 | count += 1 78 | except ConnectionError: 79 | pass 80 | -------------------------------------------------------------------------------- /tests/web_platform_tests/test_format_field_id.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime, timedelta 4 | 5 | from pytest import fail 6 | 7 | from aiohttp_sse_client import client as sse_client 8 | 9 | from .const import WPT_SERVER 10 | 11 | 12 | async def test_format_field_id(): 13 | """Test EventSource: Last-Event-ID. 14 | 15 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 16 | eventsource/format-field-id.htm 17 | """ 18 | seen_hello = False 19 | 20 | async with sse_client.EventSource( 21 | WPT_SERVER + 'resources/last-event-id.py', 22 | ) as source: 23 | async for e in source: 24 | if not seen_hello: 25 | assert e.data == "hello" 26 | seen_hello = True 27 | # default last event id is Unicode U+2026 28 | assert e.last_event_id == "…" 29 | last_id = e.last_event_id 30 | else: 31 | assert e.data == last_id 32 | assert e.last_event_id == last_id 33 | break 34 | 35 | 36 | async def test_format_field_id_2(): 37 | """Test EventSource: Last-Event-ID (2). 38 | 39 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 40 | eventsource/format-field-id-2.htm 41 | """ 42 | counter = 0 43 | 44 | async with sse_client.EventSource( 45 | WPT_SERVER + 'resources/last-event-id.py', 46 | ) as source: 47 | async for e in source: 48 | if counter == 0: 49 | counter += 1 50 | assert e.data == "hello" 51 | # default last event id is Unicode U+2026 52 | assert e.last_event_id == "…" 53 | last_id = e.last_event_id 54 | elif counter in (1, 2): 55 | counter += 1 56 | assert e.data == last_id 57 | assert e.last_event_id == last_id 58 | break 59 | else: 60 | fail("Unexpected counter {}".format(counter)) 61 | 62 | 63 | async def test_format_field_id_null(): 64 | """Test EventSource: U+0000 in id field. 65 | 66 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 67 | eventsource/format-field-id-null.htm 68 | """ 69 | seen_hello = False 70 | 71 | async with sse_client.EventSource( 72 | WPT_SERVER + 'resources/last-event-id.py?idvalue=%00%00', 73 | ) as source: 74 | async for e in source: 75 | if not seen_hello: 76 | assert e.data == "hello" 77 | seen_hello = True 78 | # Unicode U+0000 will be ignored as Event ID 79 | assert e.last_event_id == "" 80 | else: 81 | assert e.data == "hello" 82 | assert e.last_event_id == "" 83 | break 84 | -------------------------------------------------------------------------------- /tests/web_platform_tests/test_request.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import pytest 5 | 6 | from aiohttp_sse_client import client as sse_client 7 | 8 | from .const import WPT_SERVER 9 | 10 | 11 | async def test_rquest_accept(): 12 | """Test EventSource: Accept header. 13 | 14 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 15 | eventsource/request-accept.htm 16 | """ 17 | source = sse_client.EventSource( 18 | WPT_SERVER + 'resources/accept.event_stream?pipe=sub') 19 | await source.connect() 20 | async for e in source: 21 | assert e.data == "text/event-stream" 22 | break 23 | await source.close() 24 | 25 | 26 | async def test_rquest_cache_control(): 27 | """Test EventSource: Cache-Control. 28 | 29 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 30 | eventsource/request-cache-control.htm 31 | """ 32 | source = sse_client.EventSource( 33 | WPT_SERVER + 'resources/cache-control.event_stream?pipe=sub') 34 | await source.connect() 35 | async for e in source: 36 | assert e.data == "no-cache" 37 | break 38 | await source.close() 39 | 40 | 41 | async def test_rquest_redirect(): 42 | """Test EventSource: redirect. 43 | 44 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 45 | eventsource/request-redirect.htm 46 | """ 47 | async def test(status): 48 | def on_error(): 49 | assert False 50 | 51 | def on_open(): 52 | assert source.ready_state == sse_client.READY_STATE_OPEN 53 | 54 | source = sse_client.EventSource( 55 | WPT_SERVER.replace('eventsource', 'common/redirect.py?' 56 | 'location=/eventsource/resources/message.py&status=' 57 | + str(status)), 58 | on_open=on_open, 59 | on_error=on_error) 60 | await source.connect() 61 | await source.close() 62 | 63 | await test(301) 64 | await test(302) 65 | await test(303) 66 | await test(307) 67 | 68 | 69 | async def test_rquest_status_error(): 70 | """Test EventSource: redirect. 71 | 72 | ..seealso: https://github.com/web-platform-tests/wpt/blob/master/ 73 | eventsource/request-status-error.htm 74 | """ 75 | async def test(status): 76 | def on_error(): 77 | assert source.ready_state == sse_client.READY_STATE_CLOSED 78 | 79 | def on_message(): 80 | assert source.ready_state == sse_client.READY_STATE_OPEN 81 | 82 | source = sse_client.EventSource( 83 | WPT_SERVER + 'resources/status-error.py?status=' + str(status), 84 | on_message=on_message, 85 | on_error=on_error) 86 | with pytest.raises(ConnectionError): 87 | await source.connect() 88 | 89 | await test(204) 90 | await test(205) 91 | await test(210) 92 | await test(299) 93 | await test(404) 94 | await test(410) 95 | await test(503) 96 | 97 | 98 | async def test_request_post_to_connect(): 99 | """Test EventSource option method for connection. 100 | """ 101 | source = sse_client.EventSource( 102 | WPT_SERVER + 'resources/message.py', 103 | option={'method': "POST"} 104 | ) 105 | await source.connect() 106 | async for e in source: 107 | assert e.data == "data" 108 | break 109 | await source.close() 110 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/rtfol/aiohttp-sse-client/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | SSE Client could always use more documentation, whether as part of the 42 | official SSE Client docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/rtfol/aiohttp-sse-client/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `aiohttp_sse_client` for local development. 61 | 62 | 1. Fork the `aiohttp_sse_client` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/aiohttp-sse-client.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv aiohttp-sse-client 70 | $ cd aiohttp-sse-client/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 aiohttp_sse_client tests 83 | $ python setup.py test or py.test 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.6, 3.7 and 3.8. Check 106 | https://travis-ci.org/rtfol/aiohttp-sse-client/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ py.test tests.test_aiohttp_sse_client 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bumpversion patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | GitHub Actions will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # aiohttp_sse_client documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | sys.path.insert(0, os.path.abspath('..')) 24 | 25 | import aiohttp_sse_client 26 | 27 | # -- General configuration --------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'SSE Client' 51 | copyright = u"2018-2020, Jason Hu" 52 | author = u"Jason Hu" 53 | 54 | # The version info for the project you're documenting, acts as replacement 55 | # for |version| and |release|, also used in various other places throughout 56 | # the built documents. 57 | # 58 | # The short X.Y version. 59 | version = aiohttp_sse_client.__version__ 60 | # The full version, including alpha/beta/rc tags. 61 | release = aiohttp_sse_client.__version__ 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = 'sphinx' 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'alabaster' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a 90 | # theme further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = ['_static'] 99 | 100 | 101 | # -- Options for HTMLHelp output --------------------------------------- 102 | 103 | # Output file base name for HTML help builder. 104 | htmlhelp_basename = 'aiohttp_sse_clientdoc' 105 | 106 | 107 | # -- Options for LaTeX output ------------------------------------------ 108 | 109 | latex_elements = { 110 | # The paper size ('letterpaper' or 'a4paper'). 111 | # 112 | # 'papersize': 'letterpaper', 113 | 114 | # The font size ('10pt', '11pt' or '12pt'). 115 | # 116 | # 'pointsize': '10pt', 117 | 118 | # Additional stuff for the LaTeX preamble. 119 | # 120 | # 'preamble': '', 121 | 122 | # Latex figure (float) alignment 123 | # 124 | # 'figure_align': 'htbp', 125 | } 126 | 127 | # Grouping the document tree into LaTeX files. List of tuples 128 | # (source start file, target name, title, author, documentclass 129 | # [howto, manual, or own class]). 130 | latex_documents = [ 131 | (master_doc, 'aiohttp_sse_client.tex', 132 | u'SSE Client Documentation', 133 | u'Jason Hu', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output ------------------------------------ 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'aiohttp_sse_client', 143 | u'SSE Client Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'aiohttp_sse_client', 155 | u'SSE Client Documentation', 156 | author, 157 | 'aiohttp_sse_client', 158 | 'A Server-Sent Event python client base on aiohttp, provides a simple interface to process Server-Sent Event.', 159 | 'Miscellaneous'), 160 | ] 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /aiohttp_sse_client/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Main module.""" 3 | import asyncio 4 | import logging 5 | from datetime import timedelta 6 | from typing import Optional, Dict, Any 7 | 8 | import attr 9 | from aiohttp import hdrs, ClientSession, ClientConnectionError 10 | from multidict import MultiDict 11 | from yarl import URL 12 | 13 | READY_STATE_CONNECTING = 0 14 | READY_STATE_OPEN = 1 15 | READY_STATE_CLOSED = 2 16 | 17 | DEFAULT_RECONNECTION_TIME = timedelta(seconds=5) 18 | DEFAULT_MAX_CONNECT_RETRY = 5 19 | DEFAULT_MAX_READ_RETRY = 10 20 | 21 | CONTENT_TYPE_EVENT_STREAM = 'text/event-stream' 22 | LAST_EVENT_ID_HEADER = 'Last-Event-Id' 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | @attr.s(slots=True, frozen=True) 28 | class MessageEvent: 29 | """Represent DOM MessageEvent Interface 30 | 31 | .. seealso:: https://www.w3.org/TR/eventsource/#dispatchMessage section 4 32 | .. seealso:: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent 33 | """ 34 | type = attr.ib(type=str) 35 | message = attr.ib(type=str) 36 | data = attr.ib(type=str) 37 | origin = attr.ib(type=str) 38 | last_event_id = attr.ib(type=str) 39 | 40 | 41 | class EventSource: 42 | """Represent EventSource Interface as an async context manager. 43 | 44 | .. code-block:: python 45 | 46 | from aiohttp_sse_client import client as sse_client 47 | 48 | async with sse_client.EventSource( 49 | 'https://stream.wikimedia.org/v2/stream/recentchange' 50 | ) as event_source: 51 | try: 52 | async for event in event_source: 53 | print(event) 54 | except ConnectionError: 55 | pass 56 | 57 | .. seealso:: https://www.w3.org/TR/eventsource/#eventsource 58 | """ 59 | def __init__(self, url: str, 60 | option: Optional[Dict[str, Any]] = None, 61 | reconnection_time: timedelta = DEFAULT_RECONNECTION_TIME, 62 | max_connect_retry: int = DEFAULT_MAX_CONNECT_RETRY, 63 | session: Optional[ClientSession] = None, 64 | on_open=None, 65 | on_message=None, 66 | on_error=None, 67 | **kwargs): 68 | """Construct EventSource instance. 69 | 70 | :param url: specifies the URL to which to connect 71 | :param option: specifies the settings, if any, 72 | in the form of an Dict[str, Any]. Accept the "method" key for 73 | specifying the HTTP method with which connection 74 | should be established 75 | :param reconnection_time: wait time before try to reconnect in case 76 | connection broken 77 | :param session: specifies a aiohttp.ClientSession, if not, create 78 | a default ClientSession 79 | :param on_open: event handler for open event 80 | :param on_message: event handler for message event 81 | :param on_error: event handler for error event 82 | :param kwargs: keyword arguments will pass to underlying 83 | aiohttp request() method. 84 | """ 85 | self._url = URL(url) 86 | self._ready_state = READY_STATE_CONNECTING 87 | 88 | if session is not None: 89 | self._session = session 90 | self._need_close_session = False 91 | else: 92 | self._session = ClientSession() 93 | self._need_close_session = True 94 | 95 | self._on_open = on_open 96 | self._on_message = on_message 97 | self._on_error = on_error 98 | 99 | self._reconnection_time = reconnection_time 100 | self._orginal_reconnection_time = reconnection_time 101 | self._max_connect_retry = max_connect_retry 102 | self._last_event_id = '' 103 | self._kwargs = kwargs 104 | if 'headers' not in self._kwargs: 105 | self._kwargs['headers'] = MultiDict() 106 | 107 | self._event_id = '' 108 | self._event_type = '' 109 | self._event_data = '' 110 | 111 | self._origin = None 112 | self._response = None 113 | 114 | self._method = 'GET' if option is None else option.get('method', 'GET') 115 | 116 | def __enter__(self): 117 | """Use async with instead.""" 118 | raise TypeError("Use async with instead") 119 | 120 | def __exit__(self, *exc): 121 | """Should exist in pair with __enter__ but never executed.""" 122 | pass # pragma: no cover 123 | 124 | async def __aenter__(self) -> 'EventSource': 125 | """Connect and listen Server-Sent Event.""" 126 | await self.connect(self._max_connect_retry) 127 | return self 128 | 129 | async def __aexit__(self, *exc): 130 | """Close connection and session if need.""" 131 | await self.close() 132 | if self._need_close_session: 133 | await self._session.close() 134 | pass 135 | 136 | @property 137 | def url(self) -> URL: 138 | """Return URL to which to connect.""" 139 | return self._url 140 | 141 | @property 142 | def ready_state(self) -> int: 143 | """Return ready state.""" 144 | return self._ready_state 145 | 146 | def __aiter__(self): 147 | """Return""" 148 | return self 149 | 150 | async def __anext__(self) -> MessageEvent: 151 | """Process events""" 152 | if not self._response: 153 | raise ValueError 154 | 155 | # async for ... in StreamReader only split line by \n 156 | while self._response.status != 204: 157 | async for line_in_bytes in self._response.content: 158 | line = line_in_bytes.decode('utf8') # type: str 159 | line = line.rstrip('\n').rstrip('\r') 160 | 161 | if line == '': 162 | # empty line 163 | event = self._dispatch_event() 164 | if event is not None: 165 | return event 166 | continue 167 | 168 | if line[0] == ':': 169 | # comment line, ignore 170 | continue 171 | 172 | if ':' in line: 173 | # contains ':' 174 | fields = line.split(':', 1) 175 | field_name = fields[0] 176 | field_value = fields[1].lstrip(' ') 177 | self._process_field(field_name, field_value) 178 | else: 179 | self._process_field(line, '') 180 | self._ready_state = READY_STATE_CONNECTING 181 | if self._on_error: 182 | self._on_error() 183 | self._reconnection_time *= 2 184 | _LOGGER.debug('wait %s seconds for retry', 185 | self._reconnection_time.total_seconds()) 186 | await asyncio.sleep( 187 | self._reconnection_time.total_seconds()) 188 | await self.connect() 189 | raise StopAsyncIteration 190 | 191 | async def connect(self, retry=0): 192 | """Connect to resource.""" 193 | _LOGGER.debug('connect') 194 | headers = self._kwargs['headers'] 195 | 196 | # For HTTP connections, the Accept header may be included; 197 | # if included, it must contain only formats of event framing that are 198 | # supported by the user agent (one of which must be text/event-stream, 199 | # as described below). 200 | headers[hdrs.ACCEPT] = CONTENT_TYPE_EVENT_STREAM 201 | 202 | # If the event source's last event ID string is not the empty string, 203 | # then a Last-Event-Id HTTP header must be included with the request, 204 | # whose value is the value of the event source's last event ID string, 205 | # encoded as UTF-8. 206 | if self._last_event_id != '': 207 | headers[LAST_EVENT_ID_HEADER] = self._last_event_id 208 | 209 | # User agents should use the Cache-Control: no-cache header in 210 | # requests to bypass any caches for requests of event sources. 211 | headers[hdrs.CACHE_CONTROL] = 'no-cache' 212 | 213 | try: 214 | response = await self._session.request( 215 | self._method, 216 | self._url, 217 | **self._kwargs 218 | ) 219 | except ClientConnectionError: 220 | if retry <= 0 or self._ready_state == READY_STATE_CLOSED: 221 | await self._fail_connect() 222 | raise 223 | else: 224 | self._ready_state = READY_STATE_CONNECTING 225 | if self._on_error: 226 | self._on_error() 227 | self._reconnection_time *= 2 228 | _LOGGER.debug('wait %s seconds for retry', 229 | self._reconnection_time.total_seconds()) 230 | await asyncio.sleep( 231 | self._reconnection_time.total_seconds()) 232 | await self.connect(retry - 1) 233 | return 234 | 235 | if response.status >= 400 or response.status == 305: 236 | error_message = 'fetch {} failed: {}'.format( 237 | self._url, response.status) 238 | _LOGGER.error(error_message) 239 | 240 | await self._fail_connect() 241 | 242 | if response.status in [305, 401, 407]: 243 | raise ConnectionRefusedError(error_message) 244 | raise ConnectionError(error_message) 245 | 246 | if response.status != 200: 247 | error_message = 'fetch {} failed with wrong response status: {}'. \ 248 | format(self._url, response.status) 249 | _LOGGER.error(error_message) 250 | await self._fail_connect() 251 | raise ConnectionAbortedError(error_message) 252 | 253 | if response.content_type != CONTENT_TYPE_EVENT_STREAM: 254 | error_message = \ 255 | 'fetch {} failed with wrong Content-Type: {}'.format( 256 | self._url, response.headers.get(hdrs.CONTENT_TYPE)) 257 | _LOGGER.error(error_message) 258 | 259 | await self._fail_connect() 260 | raise ConnectionAbortedError(error_message) 261 | 262 | # only status == 200 and content_type == 'text/event-stream' 263 | await self._connected() 264 | 265 | self._response = response 266 | self._origin = str(response.real_url.origin()) 267 | 268 | async def close(self): 269 | """Close connection.""" 270 | _LOGGER.debug('close') 271 | self._ready_state = READY_STATE_CLOSED 272 | if self._response is not None: 273 | self._response.close() 274 | self._response = None 275 | 276 | async def _connected(self): 277 | """Announce the connection is made.""" 278 | if self._ready_state != READY_STATE_CLOSED: 279 | self._ready_state = READY_STATE_OPEN 280 | if self._on_open: 281 | self._on_open() 282 | self._reconnection_time = self._orginal_reconnection_time 283 | 284 | async def _fail_connect(self): 285 | """Announce the connection is failed.""" 286 | if self._ready_state != READY_STATE_CLOSED: 287 | self._ready_state = READY_STATE_CLOSED 288 | if self._on_error: 289 | self._on_error() 290 | pass 291 | 292 | def _dispatch_event(self): 293 | """Dispatch event.""" 294 | self._last_event_id = self._event_id 295 | 296 | if self._event_data == '': 297 | self._event_type = '' 298 | return 299 | 300 | self._event_data = self._event_data.rstrip('\n') 301 | 302 | message = MessageEvent( 303 | type=self._event_type if self._event_type != '' else None, 304 | message=self._event_type, 305 | data=self._event_data, 306 | origin=self._origin, 307 | last_event_id=self._last_event_id 308 | ) 309 | _LOGGER.debug(message) 310 | if self._on_message: 311 | self._on_message(message) 312 | 313 | self._event_type = '' 314 | self._event_data = '' 315 | return message 316 | 317 | def _process_field(self, field_name, field_value): 318 | """Process field.""" 319 | if field_name == 'event': 320 | self._event_type = field_value 321 | 322 | elif field_name == 'data': 323 | self._event_data += field_value 324 | self._event_data += '\n' 325 | 326 | elif field_name == 'id' and field_value not in ('\u0000', '\x00\x00'): 327 | self._event_id = field_value 328 | 329 | elif field_name == 'retry': 330 | try: 331 | retry_in_ms = int(field_value) 332 | self._reconnection_time = timedelta(milliseconds=retry_in_ms) 333 | except ValueError: 334 | _LOGGER.warning('Received invalid retry value %s, ignore it', 335 | field_value) 336 | pass 337 | 338 | pass 339 | --------------------------------------------------------------------------------