Repository: aio-libs/aiohttp-sse

Issue:
Accept `float` type for ping interval value
[asyncio.sleep(...) method](https://docs.python.org/3/library/asyncio-task.html#asyncio.sleep) has a `delay` arg, which in turn is used in the [loop.call_later(...)](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_later) call, which accepts a `float` type as well as `int`:

> delay number of seconds (can be either an int or a float)

Since `ping interval` is used as an argument for calling `asyncio.sleep()` only, we should accept `float` type for `ping interval` as well as `int`

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index e638aae..5b40c20 100644
--- a/README.rst
+++ b/README.rst
@@ -1,7 +1,7 @@
 aiohttp-sse
 ===========
-.. image:: https://travis-ci.org/aio-libs/aiohttp-sse.svg?branch=master
-    :target: https://travis-ci.org/aio-libs/aiohttp-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
diff --git a/aiohttp_sse/__init__.py b/aiohttp_sse/__init__.py
index d28407f..8413a1f 100644
--- a/aiohttp_sse/__init__.py
+++ b/aiohttp_sse/__init__.py
@@ -6,13 +6,7 @@ from types import TracebackType
 from typing import Any, Mapping, Optional, Type, TypeVar, Union, overload
 
 from aiohttp.abc import AbstractStreamWriter
-from aiohttp.web import (
-    BaseRequest,
-    ContentCoding,
-    HTTPMethodNotAllowed,
-    Request,
-    StreamResponse,
-)
+from aiohttp.web import BaseRequest, ContentCoding, Request, StreamResponse
 
 from .helpers import _ContextManager
 
@@ -57,7 +51,7 @@ class EventSourceResponse(StreamResponse):
         self.headers["Connection"] = "keep-alive"
         self.headers["X-Accel-Buffering"] = "no"
 
-        self._ping_interval: int = self.DEFAULT_PING_INTERVAL
+        self._ping_interval: float = self.DEFAULT_PING_INTERVAL
         self._ping_task: Optional[asyncio.Task[None]] = None
         self._sep = sep if sep is not None else self.DEFAULT_SEPARATOR
 
@@ -78,9 +72,6 @@ class EventSourceResponse(StreamResponse):
 
         :param request: regular aiohttp.web.Request.
         """
-        if request.method != "GET":
-            raise HTTPMethodNotAllowed(request.method, ["GET"])
-
         if not self.prepared:
             writer = await super().prepare(request)
             self._ping_task = asyncio.create_task(self._ping())
@@ -173,19 +164,19 @@ class EventSourceResponse(StreamResponse):
         return self._req.headers.get(self.DEFAULT_LAST_EVENT_HEADER)
 
     @property
-    def ping_interval(self) -> int:
+    def ping_interval(self) -> float:
         """Time interval between two ping massages"""
         return self._ping_interval
 
     @ping_interval.setter
-    def ping_interval(self, value: int) -> None:
+    def ping_interval(self, value: float) -> None:
         """Setter for ping_interval property.
 
-        :param int value: interval in sec between two ping values.
+        :param value: interval in sec between two ping values.
         """
 
-        if not isinstance(value, int):
-            raise TypeError("ping interval must be int")
+        if not isinstance(value, (int, float)):
+            raise TypeError("ping interval must be int or float")
         if value < 0:
             raise ValueError("ping interval must be greater then 0")
 
diff --git a/requirements.txt b/requirements.txt
index 8ca49e7..90a262d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
 -e .
 aiohttp==3.9.3
 pytest==7.4.4
+pytest-aiohttp==1.0.5
 pytest-asyncio==0.23.4
 pytest-cov==4.1.0


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index a07132d..99f74d9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,6 @@
 from asyncio import AbstractEventLoop
-from typing import AsyncIterator, cast
+from typing import cast
 
-import aiohttp
 import pytest
 
 
@@ -18,9 +17,3 @@ def debug(request: pytest.FixtureRequest) -> bool:
 def loop(event_loop: AbstractEventLoop, debug: bool) -> AbstractEventLoop:
     event_loop.set_debug(debug)
     return event_loop
-
-
-@pytest.fixture
-async def session() -> AsyncIterator[aiohttp.ClientSession]:
-    async with aiohttp.ClientSession() as session:
-        yield session
diff --git a/tests/test_sse.py b/tests/test_sse.py
index 03489b8..dc3b4b4 100644
--- a/tests/test_sse.py
+++ b/tests/test_sse.py
@@ -1,21 +1,15 @@
 import asyncio
-from typing import List
+from typing import Awaitable, Callable, List
 
 import pytest
-from aiohttp import ClientSession, web
-from aiohttp.test_utils import make_mocked_request
+from aiohttp import web
+from aiohttp.test_utils import TestClient, make_mocked_request
 
 from aiohttp_sse import EventSourceResponse, sse_response
 
-socket = web.AppKey("socket", List[EventSourceResponse])
-
+ClientFixture = Callable[[web.Application], Awaitable[TestClient]]
 
-async def make_runner(app: web.Application, host: str, port: int) -> web.AppRunner:
-    runner = web.AppRunner(app)
-    await runner.setup()
-    site = web.TCPSite(runner, host, port)
-    await site.start()
-    return runner
+socket = web.AppKey("socket", List[EventSourceResponse])
 
 
 @pytest.mark.parametrize(
@@ -23,11 +17,7 @@ async def make_runner(app: web.Application, host: str, port: int) -> web.AppRunn
     (False, True),
     ids=("without_sse_response", "with_sse_response"),
 )
-async def test_func(
-    unused_tcp_port: int,
-    with_sse_response: bool,
-    session: ClientSession,
-) -> None:
+async def test_func(with_sse_response: bool, aiohttp_client: ClientFixture) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
         if with_sse_response:
             resp = await sse_response(request, headers={"X-SSE": "aiohttp_sse"})
@@ -46,12 +36,8 @@ async def test_func(
     app.router.add_route("GET", "/", func)
     app.router.add_route("POST", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-
-    url = f"http://127.0.0.1:{unused_tcp_port}/"
-
-    resp = await session.request("GET", url)
+    client = await aiohttp_client(app)
+    resp = await client.get("/")
     assert 200 == resp.status
 
     # make sure that EventSourceResponse supports passing
@@ -74,18 +60,8 @@ async def test_func(
     )
     assert streamed_data == expected
 
-    # check that EventSourceResponse object works only
-    # with GET method
-    resp = await session.request("POST", url)
-    assert 405 == resp.status
-    resp.close()
-    await runner.cleanup()
-
 
-async def test_wait_stop_streaming(
-    unused_tcp_port: int,
-    session: ClientSession,
-) -> None:
+async def test_wait_stop_streaming(aiohttp_client: ClientFixture) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
         app = request.app
         resp = EventSourceResponse()
@@ -99,11 +75,8 @@ async def test_wait_stop_streaming(
     app[socket] = []  # type: ignore[misc]
     app.router.add_route("GET", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-    url = f"http://127.0.0.1:{unused_tcp_port}/"
-
-    resp_task = asyncio.create_task(session.request("GET", url))
+    client = await aiohttp_client(app)
+    resp_task = asyncio.create_task(client.get("/"))
 
     await asyncio.sleep(0.1)
     esourse = app[socket][0]
@@ -117,13 +90,8 @@ async def test_wait_stop_streaming(
     expected = "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n"
     assert streamed_data == expected
 
-    await runner.cleanup()
-
 
-async def test_retry(
-    unused_tcp_port: int,
-    session: ClientSession,
-) -> None:
+async def test_retry(aiohttp_client: ClientFixture) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
         resp = EventSourceResponse()
         await resp.prepare(request)
@@ -137,11 +105,8 @@ async def test_retry(
     app = web.Application()
     app.router.add_route("GET", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-    url = f"http://127.0.0.1:{unused_tcp_port}/"
-
-    resp = await session.request("GET", url)
+    client = await aiohttp_client(app)
+    resp = await client.get("/")
     assert 200 == resp.status
 
     # check streamed data
@@ -149,8 +114,6 @@ async def test_retry(
     expected = "data: foo\r\nretry: 1\r\n\r\n"
     assert streamed_data == expected
 
-    await runner.cleanup()
-
 
 async def test_wait_stop_streaming_errors() -> None:
     response = EventSourceResponse()
@@ -170,19 +133,19 @@ def test_compression_not_implemented() -> None:
 
 
 class TestPingProperty:
-    @pytest.mark.parametrize("value", [25, 0], ids=("int", "zero int"))
-    def test_success(self, value: int) -> None:
+    @pytest.mark.parametrize("value", (25, 25.0, 0), ids=("int", "float", "zero int"))
+    def test_success(self, value: float) -> None:
         response = EventSourceResponse()
         response.ping_interval = value
         assert response.ping_interval == value
 
     @pytest.mark.parametrize("value", [None, "foo"], ids=("None", "str"))
-    def test_wrong_type(self, value: int) -> None:
+    def test_wrong_type(self, value: float) -> None:
         response = EventSourceResponse()
         with pytest.raises(TypeError) as ctx:
             response.ping_interval = value
 
-        assert ctx.match("ping interval must be int")
+        assert ctx.match("ping interval must be int or float")
 
     def test_negative_int(self) -> None:
         response = EventSourceResponse()
@@ -196,7 +159,7 @@ class TestPingProperty:
         assert response.ping_interval == response.DEFAULT_PING_INTERVAL
 
 
-async def test_ping(unused_tcp_port: int, session: ClientSession) -> None:
+async def test_ping(aiohttp_client: ClientFixture) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
         app = request.app
         resp = EventSourceResponse()
@@ -211,11 +174,8 @@ async def test_ping(unused_tcp_port: int, session: ClientSession) -> None:
     app[socket] = []  # type: ignore[misc]
     app.router.add_route("GET", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-    url = f"http://127.0.0.1:{unused_tcp_port}/"
-
-    resp_task = asyncio.create_task(session.request("GET", url))
+    client = await aiohttp_client(app)
+    resp_task = asyncio.create_task(client.get("/"))
 
     await asyncio.sleep(1.15)
     esourse = app[socket][0]
@@ -228,12 +188,10 @@ async def test_ping(unused_tcp_port: int, session: ClientSession) -> None:
 
     expected = "data: foo\r\n\r\n" + ": ping\r\n\r\n"
     assert streamed_data == expected
-    await runner.cleanup()
 
 
 async def test_ping_reset(
-    unused_tcp_port: int,
-    session: ClientSession,
+    aiohttp_client: ClientFixture,
     monkeypatch: pytest.MonkeyPatch,
 ) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
@@ -250,11 +208,8 @@ async def test_ping_reset(
     app[socket] = []  # type: ignore[misc]
     app.router.add_route("GET", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-    url = f"http://127.0.0.1:{unused_tcp_port}/"
-
-    resp_task = asyncio.create_task(session.request("GET", url))
+    client = await aiohttp_client(app)
+    resp_task = asyncio.create_task(client.get("/"))
 
     await asyncio.sleep(1.15)
     esource = app[socket][0]
@@ -273,10 +228,9 @@ async def test_ping_reset(
 
     expected = "data: foo\r\n\r\n" + ": ping\r\n\r\n"
     assert streamed_data == expected
-    await runner.cleanup()
 
 
-async def test_context_manager(unused_tcp_port: int, session: ClientSession) -> None:
+async def test_context_manager(aiohttp_client: ClientFixture) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
         h = {"X-SSE": "aiohttp_sse"}
         async with sse_response(request, headers=h) as sse:
@@ -290,11 +244,8 @@ async def test_context_manager(unused_tcp_port: int, session: ClientSession) ->
     app.router.add_route("GET", "/", func)
     app.router.add_route("POST", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-    url = f"http://127.0.0.1:{unused_tcp_port}/"
-
-    resp = await session.request("GET", url)
+    client = await aiohttp_client(app)
+    resp = await client.get("/")
     assert resp.status == 200
 
     # make sure that EventSourceResponse supports passing
@@ -310,7 +261,6 @@ async def test_context_manager(unused_tcp_port: int, session: ClientSession) ->
         "id: xyz\r\nevent: bar\r\ndata: foo\r\nretry: 1\r\n\r\n"
     )
     assert streamed_data == expected
-    await runner.cleanup()
 
 
 class TestCustomResponseClass:
@@ -334,9 +284,7 @@ class TestCustomResponseClass:
 
 
 @pytest.mark.parametrize("sep", ["\n", "\r", "\r\n"], ids=("LF", "CR", "CR+LF"))
-async def test_custom_sep(
-    unused_tcp_port: int, session: ClientSession, sep: str
-) -> None:
+async def test_custom_sep(aiohttp_client: ClientFixture, sep: str) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
         h = {"X-SSE": "aiohttp_sse"}
         async with sse_response(request, headers=h, sep=sep) as sse:
@@ -349,11 +297,8 @@ async def test_custom_sep(
     app = web.Application()
     app.router.add_route("GET", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-    url = f"http://127.0.0.1:{unused_tcp_port}/"
-
-    resp = await session.request("GET", url)
+    client = await aiohttp_client(app)
+    resp = await client.get("/")
     assert resp.status == 200
 
     # make sure that EventSourceResponse supports passing
@@ -370,7 +315,6 @@ async def test_custom_sep(
     )
 
     assert streamed_data == expected.format(sep)
-    await runner.cleanup()
 
 
 @pytest.mark.parametrize(
@@ -426,8 +370,7 @@ async def test_custom_sep(
     ),
 )
 async def test_multiline_data(
-    unused_tcp_port: int,
-    session: ClientSession,
+    aiohttp_client: ClientFixture,
     stream_sep: str,
     line_sep: str,
 ) -> None:
@@ -444,11 +387,8 @@ async def test_multiline_data(
     app = web.Application()
     app.router.add_route("GET", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-    url = f"http://127.0.0.1:{unused_tcp_port}/"
-
-    resp = await session.request("GET", url)
+    client = await aiohttp_client(app)
+    resp = await client.get("/")
     assert resp.status == 200
 
     # make sure that EventSourceResponse supports passing
@@ -465,13 +405,10 @@ async def test_multiline_data(
         "retry: 1{0}{0}"
     )
     assert streamed_data == expected.format(stream_sep)
-    await runner.cleanup()
 
 
 class TestSSEState:
-    async def test_context_states(
-        self, unused_tcp_port: int, session: ClientSession
-    ) -> None:
+    async def test_context_states(self, aiohttp_client: ClientFixture) -> None:
         async def func(request: web.Request) -> web.StreamResponse:
             async with sse_response(request) as resp:
                 assert resp.is_connected()
@@ -482,50 +419,37 @@ class TestSSEState:
         app = web.Application()
         app.router.add_route("GET", "/", func)
 
-        host = "127.0.0.1"
-        runner = await make_runner(app, host, unused_tcp_port)
-        await session.request("GET", f"http://{host}:{unused_tcp_port}/")
-        await runner.cleanup()
+        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(
-    unused_tcp_port: int,
-    session: ClientSession,
-) -> None:
+async def test_connection_is_not_alive(aiohttp_client: ClientFixture) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
         # within context manager first preparation is already done
-        async with sse_response(request) as resp:
-            resp.ping_interval = 1
-
-            # we should sleep to switch asyncio Task
-            # and let connection to be closed
-            while resp.is_connected():
-                await asyncio.sleep(0.01)
+        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 resp.prepare(request)
+                await sse.prepare(request)
 
-            return resp  # pragma: no cover
+            return sse  # pragma: no cover
 
     app = web.Application()
     app.router.add_route("GET", "/", func)
 
-    host = "127.0.0.1"
-    runner = await make_runner(app, host, unused_tcp_port)
-
-    async with session.get(f"http://{host}:{unused_tcp_port}/") as resp:
+    client = await aiohttp_client(app)
+    async with client.get("/") as resp:
         assert resp.status == 200
 
-    await runner.cleanup()
-
 
 class TestLastEventId:
-    async def test_success(self, unused_tcp_port: int, session: ClientSession) -> None:
+    async def test_success(self, aiohttp_client: ClientFixture) -> None:
         async def func(request: web.Request) -> web.StreamResponse:
             async with sse_response(request) as sse:
                 assert sse.last_event_id is not None
@@ -535,21 +459,42 @@ class TestLastEventId:
         app = web.Application()
         app.router.add_route("GET", "/", func)
 
-        host = "127.0.0.1"
-        runner = await make_runner(app, host, unused_tcp_port)
-        url = f"http://{host}:{unused_tcp_port}/"
+        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}
-        resp = await session.request("GET", url, headers=headers)
-        assert resp.status == 200
+        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"
-        await runner.cleanup()
+            # check streamed data
+            streamed_data = await resp.text()
+            assert streamed_data == f"data: {last_event_id}\r\n\r\n"
 
     async def test_get_before_prepare(self) -> None:
         sse = EventSourceResponse()
         with pytest.raises(RuntimeError):
             _ = sse.last_event_id
+
+
+@pytest.mark.parametrize(
+    "http_method",
+    ("GET", "POST", "PUT", "DELETE", "PATCH"),
+)
+async def test_http_methods(aiohttp_client: ClientFixture, http_method: str) -> None:
+    async def handler(request: web.Request) -> EventSourceResponse:
+        async with sse_response(request) as sse:
+            await sse.send("foo")
+        return sse
+
+    app = web.Application()
+    app.router.add_route(http_method, "/", handler)
+
+    client = await aiohttp_client(app)
+    async with client.request(http_method, "/") as resp:
+        assert resp.status == 200
+        # check streamed data
+        streamed_data = await resp.text()
+
+    assert streamed_data == "data: foo\r\n\r\n"