Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

V6 proposition #156

Draft
wants to merge 15 commits into
base: master
Choose a base branch
from
30 changes: 30 additions & 0 deletions .github/workflows/django.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Django CI

on:
push:
pull_request:
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
cd tests/
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install ../
- name: Run Tests
run: |
pytest
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ db.sqlite3
db.sqlite3-journal
build/
dist/
htmlcov/
*/.coverage
.pytest_cache/
13 changes: 13 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
},
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": [
"-v",
"-s",
"./tests/",
]
}
30 changes: 14 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,23 +191,21 @@ urlpatterns = [
]
```

Next, use `DEFAULT_RENDERER_CLASSES` in `settings.py` to manage the renderers you want to use. The `django_eventstream.renderers.SSEEventRenderer` is required to enable SSE functionality. If you also want the Browsable API view, add `django_eventstream.renderers.BrowsableAPIEventStreamRenderer`.

Example:

```python
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
'django_eventstream.renderers.SSEEventRenderer',
'django_eventstream.renderers.BrowsableAPIEventStreamRenderer'
# Add other renderers as needed
]
Next, if you want to customize the rendering by using your own or if you want to delet the Browsable API version here the steps:

Firstly some context, the Django EventStream module provide two rederers, the `BrowsableAPIEventStreamRenderer` that is a subclass of the `BrowsableAPIRenderer` which provides a browsable API for the event stream and the `EventStreamRenderer` that is a subclass of the `BaseRenderer` that provides the SSE format for the event stream.

To remove the Browsable APIor to modifi the used renderer you need to set the `EVENTSTREAM_RENDERER` in the settings.py file. Same as you can do with the Django REST Framework renderers.

Here is an example of the `settings.py` file:
```py
EVENTSTREAM_RENDERER = {
'django_eventstream.renderers.SSEEventRenderer',
'django_eventstream.renderers.BrowsableAPIEventStreamRenderer',
}
```

Be careful to not add the eventstream renderers before the `JSONRenderer` and `BrowsableAPIRenderer` (or other Renderer), otherwise the API will probably not work as expected.
Note that the browsable API renderer is only available if an SSE renderer is avaible to to properly stream the data.

## Event storage

Expand All @@ -234,8 +232,8 @@ To enable storage selectively by channel, implement a channel manager and overri
Include client libraries on the frontend:

```html
<script src="{% static 'django_eventstream/eventsource.min.js' %}"></script>
<script src="{% static 'django_eventstream/reconnecting-eventsource.js' %}"></script>
<script src="{% static 'django_eventstream/js/eventsource.min.js' %}"></script>
<script src="{% static 'django_eventstream/js/reconnecting-eventsource.js' %}"></script>
```

Listen for data:
Expand Down
2 changes: 2 additions & 0 deletions django_eventstream/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@
)

from . import urls

from .event import Event
6 changes: 0 additions & 6 deletions django_eventstream/admin.py

This file was deleted.

77 changes: 70 additions & 7 deletions django_eventstream/event.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,75 @@
from dataclasses import dataclass
from typing import Optional

# Object class replaced with dataclass for type safety
from dataclasses import dataclass, field
from typing import Optional, Dict
import re
import six
import warnings


@dataclass
class Event:
channel: str
type: str
data: dict
channel: str = ""
type: str = "message"
data: Dict = field(default_factory=dict)
id: Optional[int] = None
retry: Optional[int] = None

# Regex pattern for parsing SSE lines
sse_line_pattern: re.Pattern = field(
init=False, default=re.compile(r"(?P<name>[^:]*):?( ?(?P<value>.*))?")
)

def __str__(self):
return f"Event(channel={self.channel}, type={self.type}, data={self.data}, id={self.id}, retry={self.retry})"

@classmethod
def parse(cls, raw: str) -> "Event":
"""
Given a possibly-multiline string representing an SSE message, parse it
and return an Event object.
"""
msg = cls()
for line in raw.splitlines():
m = cls.sse_line_pattern.match(line)
if m is None:
# Malformed line. Discard but warn.
warnings.warn(f'Invalid SSE line: "{line}"', SyntaxWarning)
continue

name = m.group("name")
if name == "":
# line began with a ":", so is a comment. Ignore
continue
value = m.group("value")

if name == "data":
# If we already have some data, then join to it with a newline.
# Else this is it.
if "data" in msg.data:
msg.data["data"] += f"\n{value}"
else:
msg.data["data"] = value
elif name == "event":
msg.type = value
elif name == "id":
msg.id = value
elif name == "retry":
msg.retry = int(value)

return msg

def dump(self) -> str:
"""
Convert the Event object back to a string format suitable for SSE transmission.
"""
lines = []
if self.id is not None:
lines.append(f"id: {self.id}")

if self.type != "message":
lines.append(f"event: {self.type}")

if self.retry is not None:
lines.append(f"retry: {self.retry}")

lines.extend(f"data: {line}" for line in self.data["data"].splitlines())
return "\n".join(lines) + "\n\n"
2 changes: 2 additions & 0 deletions django_eventstream/eventstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def send_event(
blocking=(not async_publish),
)

return e


def get_events(request, limit=100, user=None):
if user is None:
Expand Down
13 changes: 9 additions & 4 deletions django_eventstream/renderers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
try:
from .sserenderer import SSEEventRenderer
import importlib

if importlib.util.find_spec("rest_framework") is None:
from django_eventstream.utils import raise_not_found_module_error

BrowsableAPIEventStreamRenderer = raise_not_found_module_error
SSEEventRenderer = raise_not_found_module_error
else:
from .browsableapieventstreamrenderer import BrowsableAPIEventStreamRenderer
except ImportError:
pass
from .sserenderer import SSEEventRenderer
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ <h1>{{ name }}</h1>
{% if channels %}<b>Listen channels:</b> <span class="lit">{{ channels }}</span>{% endif %}
<b>Listen messages types:</b> <span class="lit">{{ messages_types }}</span>
<hr><div class="action-container"></span>{% if channels and not error %}<button id="clearMessages" class="btn btn-danger"><i class="fas fa-trash"></i> Clear Messages</button><div class="custom-panel-body"><div class="custom-checkbox custom-checkbox-switch custom-switch-primary"><label><input type="checkbox" id="keepAliveCheckbox" name="keepAliveCheckbox" checked /><span class="custom-slider"></span>Keep-Alive event</label></div></div></div>
<div id="sse-stream"></div>{% endif %}{% if error %} <div class="error-channels"><b>Error: </b>{{ error }}{% endif %}</div></pre>
<div id="sse-stream"></div>{% endif %}{% if error %} <div class="error-channels text-danger"><b>Error: </b>{{ error }}{% endif %}</div></pre>
</div>
</div>

Expand Down Expand Up @@ -311,9 +311,9 @@ <h1>{{ name }}</h1>
{% endblock %}

{% if channels and not error %}
<script src="{% static 'django_eventstream/json2.js' %}"></script>
<script src="{% static 'django_eventstream/reconnecting-eventsource.js' %}"></script>
<script src="{% static 'django_eventstream/eventsource.min.js' %}"></script>
<script src="{% static 'django_eventstream/js/json2.js' %}"></script>
<script src="{% static 'django_eventstream/js/reconnecting-eventsource.js' %}"></script>
<script src="{% static 'django_eventstream/js/eventsource.min.js' %}"></script>
<script>
var messages_types = "{{ messages_types|safe }}";
</script>
Expand Down
6 changes: 0 additions & 6 deletions django_eventstream/tests.py

This file was deleted.

13 changes: 13 additions & 0 deletions django_eventstream/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from .sseclient import SSEClient
from .basesseclient import BaseSSEClient


import importlib

if importlib.util.find_spec("rest_framework") is None:
from django_eventstream.utils import raise_not_found_module_error

SSEAPIClient = raise_not_found_module_error

else:
from .sseapiclient import SSEAPIClient
50 changes: 50 additions & 0 deletions django_eventstream/tests/basesseclient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import re
import codecs
import asyncio
from django_eventstream.event import Event


class BaseSSEClient:
"""
This class provides a way to properly consume Server-Sent Events (SSE) in a synchronous way.
It also provide a default headers to be used in the request to ensure that the response will be in the correct format.
"""

end_of_field = re.compile(r"\r\n\r\n|\r\r|\n\n")

def __init__(self):
self.response = None
self.default_headers = {
"Accept": "text/event-stream",
"Content-Type": "text/event-stream",
}

def sse_stream(self, response=None):
if self.response is None:
raise ValueError("You must set a response before calling `sse_stream_sync`")
if not getattr(self.response, "streaming", False):
raise ValueError("The response is not streaming")

encoding = getattr(self.response, "encoding", None) or "utf-8"
decoder = codecs.getincrementaldecoder(encoding)(errors="replace")

async def async_event_generator():
buf = ""
async for chunk in self.response.streaming_content:
buf += decoder.decode(chunk)
while re.search(self.end_of_field, buf):
event_string, buf = re.split(self.end_of_field, buf, maxsplit=1)
event = Event.parse(event_string)
yield event

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
generator = async_event_generator()
try:
while True:
event = loop.run_until_complete(generator.__anext__())
yield event
except StopAsyncIteration:
pass
finally:
loop.close()
19 changes: 19 additions & 0 deletions django_eventstream/tests/sseapiclient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from rest_framework.test import APIClient
from .basesseclient import BaseSSEClient


class SSEAPIClient(APIClient, BaseSSEClient):
def __init__(self, *args, **kwargs):
APIClient.__init__(self, *args, **kwargs)
BaseSSEClient.__init__(self)

def get(self, *args, **kwargs):
if "headers" in kwargs:
headers = {**self.default_headers, **kwargs["headers"]}
else:
headers = self.default_headers

kwargs["headers"] = headers

self.response = super().get(*args, **kwargs)
return self.response
19 changes: 19 additions & 0 deletions django_eventstream/tests/sseclient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.test import Client
from .basesseclient import BaseSSEClient


class SSEClient(Client, BaseSSEClient):
def __init__(self, *args, **kwargs):
Client.__init__(self, *args, **kwargs)
BaseSSEClient.__init__(self)

def get(self, *args, **kwargs):
if "headers" in kwargs:
headers = {**self.default_headers, **kwargs["headers"]}
else:
headers = self.default_headers

kwargs["headers"] = headers

self.response = super().get(*args, **kwargs)
return self.response
6 changes: 6 additions & 0 deletions django_eventstream/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,9 @@ def augment_cors_headers(headers, request):

if allow_headers:
headers["Access-Control-Allow-Headers"] = allow_headers


def raise_not_found_module_error(*args, **kwargs):
raise ModuleNotFoundError(
"Django Rest Framework is not installed. Please install it to use this feature."
)
19 changes: 19 additions & 0 deletions django_eventstream/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from .views import (
Listener,
RedisListener,
ListenerManager,
get_listener_manager,
stream,
events,
)

import importlib

if importlib.util.find_spec("rest_framework") is None:
from django_eventstream.utils import raise_not_found_module_error

EventsAPIView = raise_not_found_module_error
configure_events_api_view = raise_not_found_module_error
EventsMetadata = raise_not_found_module_error
else:
from .apiviews import EventsAPIView, configure_events_api_view, EventsMetadata
Loading