microdot 2.3.1__tar.gz → 2.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {microdot-2.3.1/src/microdot.egg-info → microdot-2.3.3}/PKG-INFO +1 -1
- {microdot-2.3.1 → microdot-2.3.3}/docs/api.rst +2 -0
- {microdot-2.3.1 → microdot-2.3.3}/docs/extensions.rst +52 -5
- {microdot-2.3.1 → microdot-2.3.3}/docs/intro.rst +34 -15
- {microdot-2.3.1 → microdot-2.3.3}/pyproject.toml +1 -1
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/auth.py +2 -2
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/microdot.py +37 -5
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/sse.py +13 -2
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/test_client.py +40 -4
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/websocket.py +4 -4
- {microdot-2.3.1 → microdot-2.3.3/src/microdot.egg-info}/PKG-INFO +1 -1
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_sse.py +37 -0
- {microdot-2.3.1 → microdot-2.3.3}/LICENSE +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/MANIFEST.in +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/README.md +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/docs/Makefile +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/docs/_static/css/custom.css +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/docs/conf.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/docs/freezing.rst +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/docs/index.rst +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/docs/make.bat +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/docs/migrating.rst +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/setup.cfg +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/__init__.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/asgi.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/cors.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/helpers.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/jinja.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/login.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/multipart.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/session.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/utemplate.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot/wsgi.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot.egg-info/SOURCES.txt +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot.egg-info/dependency_links.txt +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot.egg-info/not-zip-safe +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot.egg-info/requires.txt +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/src/microdot.egg-info/top_level.txt +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/__init__.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.bin +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.css +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.gif +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.gz +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.html +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.jpg +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.js +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.json +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.png +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.txt +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/files/test.txt.gz +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/mock_socket.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/templates/hello.jinja.txt +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/templates/hello.utemplate.txt +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/templates/hello_utemplate_txt.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_asgi.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_auth.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_cors.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_end2end.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_jinja.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_login.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_microdot.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_multidict.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_multipart.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_request.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_response.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_session.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_url_pattern.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_urlencode.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_utemplate.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_websocket.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tests/test_wsgi.py +0 -0
- {microdot-2.3.1 → microdot-2.3.3}/tox.ini +0 -0
|
@@ -116,6 +116,33 @@ Example::
|
|
|
116
116
|
message = await ws.receive()
|
|
117
117
|
await ws.send(message)
|
|
118
118
|
|
|
119
|
+
To end the WebSocket connection, the route handler can exit, without returning
|
|
120
|
+
anything::
|
|
121
|
+
|
|
122
|
+
@app.route('/echo')
|
|
123
|
+
@with_websocket
|
|
124
|
+
async def echo(request, ws):
|
|
125
|
+
while True:
|
|
126
|
+
message = await ws.receive()
|
|
127
|
+
if message == 'exit':
|
|
128
|
+
break
|
|
129
|
+
await ws.send(message)
|
|
130
|
+
await ws.send('goodbye')
|
|
131
|
+
|
|
132
|
+
If the client ends the WebSocket connection from their side, the route function
|
|
133
|
+
is cancelled. The route function can catch the ``CancelledError`` exception
|
|
134
|
+
from asyncio to perform cleanup tasks::
|
|
135
|
+
|
|
136
|
+
@app.route('/echo')
|
|
137
|
+
@with_websocket
|
|
138
|
+
async def echo(request, ws):
|
|
139
|
+
try:
|
|
140
|
+
while True:
|
|
141
|
+
message = await ws.receive()
|
|
142
|
+
await ws.send(message)
|
|
143
|
+
except asyncio.CancelledError:
|
|
144
|
+
print('Client disconnected!')
|
|
145
|
+
|
|
119
146
|
Server-Sent Events
|
|
120
147
|
~~~~~~~~~~~~~~~~~~
|
|
121
148
|
|
|
@@ -153,6 +180,25 @@ Example::
|
|
|
153
180
|
await sse.send({'counter': i}) # unnamed event
|
|
154
181
|
await sse.send('end', event='comment') # named event
|
|
155
182
|
|
|
183
|
+
To end the SSE connection, the route handler can exit, without returning
|
|
184
|
+
anything, as shown in the above examples.
|
|
185
|
+
|
|
186
|
+
If the client ends the SSE connection from their side, the route function is
|
|
187
|
+
cancelled. The route function can catch the ``CancelledError`` exception from
|
|
188
|
+
asyncio to perform cleanup tasks::
|
|
189
|
+
|
|
190
|
+
@app.route('/events')
|
|
191
|
+
@with_sse
|
|
192
|
+
async def events(request, sse):
|
|
193
|
+
try:
|
|
194
|
+
i = 0
|
|
195
|
+
while True:
|
|
196
|
+
await asyncio.sleep(1)
|
|
197
|
+
await sse.send({'counter': i})
|
|
198
|
+
i += 1
|
|
199
|
+
except asyncio.CancelledError:
|
|
200
|
+
print('Client disconnected!')
|
|
201
|
+
|
|
156
202
|
.. note::
|
|
157
203
|
The SSE protocol is unidirectional, so there is no ``receive()`` method in
|
|
158
204
|
the SSE object. For bidirectional communication with the client, use the
|
|
@@ -420,13 +466,13 @@ be protected with the ``auth.optional`` decorator::
|
|
|
420
466
|
@app.route('/')
|
|
421
467
|
@auth.optional
|
|
422
468
|
async def index(request):
|
|
423
|
-
if g.current_user:
|
|
469
|
+
if request.g.current_user:
|
|
424
470
|
return f'Hello, {request.g.current_user}!'
|
|
425
471
|
else:
|
|
426
472
|
return 'Hello, anonymous user!'
|
|
427
473
|
|
|
428
|
-
As shown in the example, a route can check ``g.current_user`` to
|
|
429
|
-
the user is authenticated or not.
|
|
474
|
+
As shown in the example, a route can check ``request.g.current_user`` to
|
|
475
|
+
determine if the user is authenticated or not.
|
|
430
476
|
|
|
431
477
|
Token Authentication
|
|
432
478
|
^^^^^^^^^^^^^^^^^^^^
|
|
@@ -446,7 +492,8 @@ or ``None`` if the token is invalid or expired::
|
|
|
446
492
|
return load_user_from_token(token)
|
|
447
493
|
|
|
448
494
|
As with Basic authentication, the ``auth`` instance is used as a decorator to
|
|
449
|
-
protect your routes
|
|
495
|
+
protect your routes, and the authenticated user is accessible from the request
|
|
496
|
+
object as ``request.g.current_user``::
|
|
450
497
|
|
|
451
498
|
@app.route('/')
|
|
452
499
|
@auth
|
|
@@ -458,7 +505,7 @@ Optional authentication can also be used with tokens::
|
|
|
458
505
|
@app.route('/')
|
|
459
506
|
@auth.optional
|
|
460
507
|
async def index(request):
|
|
461
|
-
if g.current_user:
|
|
508
|
+
if request.g.current_user:
|
|
462
509
|
return f'Hello, {request.g.current_user}!'
|
|
463
510
|
else:
|
|
464
511
|
return 'Hello, anonymous user!'
|
|
@@ -601,6 +601,13 @@ The request object provides access to the request attributes, including:
|
|
|
601
601
|
specified by the client, or ``None`` if no content type was specified.
|
|
602
602
|
- :attr:`content_length <microdot.Request.content_length>`: The content
|
|
603
603
|
length of the request, or 0 if no content length was specified.
|
|
604
|
+
- :attr:`json <microdot.Request.json>`: The parsed JSON data in the request
|
|
605
|
+
body. See :ref:`below <JSON Payloads>` for additional details.
|
|
606
|
+
- :attr:`form <microdot.Request.form>`: The parsed form data in the request
|
|
607
|
+
body, as a dictionary. See :ref:`below <Form Data>` for additional details.
|
|
608
|
+
- :attr:`files <microdot.Request.files>`: A dictionary with the file uploads
|
|
609
|
+
included in the request body. Note that file uploads are only supported when
|
|
610
|
+
the :ref:`Multipart Forms` extension is used.
|
|
604
611
|
- :attr:`client_addr <microdot.Request.client_addr>`: The network address of
|
|
605
612
|
the client, as a tuple (host, port).
|
|
606
613
|
- :attr:`app <microdot.Request.app>`: The application instance that created the
|
|
@@ -627,8 +634,8 @@ to use this attribute::
|
|
|
627
634
|
The client must set the ``Content-Type`` header to ``application/json`` for
|
|
628
635
|
the ``json`` attribute of the request object to be populated.
|
|
629
636
|
|
|
630
|
-
|
|
631
|
-
|
|
637
|
+
Form Data
|
|
638
|
+
^^^^^^^^^
|
|
632
639
|
|
|
633
640
|
The request object also supports standard HTML form submissions through the
|
|
634
641
|
:attr:`form <microdot.Request.form>` attribute, which presents the form data
|
|
@@ -642,9 +649,10 @@ as a :class:`MultiDict <microdot.MultiDict>` object. Example::
|
|
|
642
649
|
return f'Hello {name}'
|
|
643
650
|
|
|
644
651
|
.. note::
|
|
645
|
-
Form submissions
|
|
646
|
-
the client to ``application/x-www-form-urlencoded``.
|
|
647
|
-
the ``multipart/form-data`` content type
|
|
652
|
+
Form submissions automatically parsed when the ``Content-Type`` header is
|
|
653
|
+
set by the client to ``application/x-www-form-urlencoded``. For form
|
|
654
|
+
submissions that use the ``multipart/form-data`` content type the
|
|
655
|
+
:ref:`Multipart Forms` extension must be used.
|
|
648
656
|
|
|
649
657
|
Accessing the Raw Request Body
|
|
650
658
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
@@ -749,15 +757,18 @@ sections describe the different types of responses that are supported.
|
|
|
749
757
|
The Three Parts of a Response
|
|
750
758
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
751
759
|
|
|
752
|
-
Route functions can return one, two or three values. The first
|
|
753
|
-
|
|
760
|
+
Route functions can return one, two or three values. The first and most
|
|
761
|
+
important value is the response body::
|
|
754
762
|
|
|
755
763
|
@app.get('/')
|
|
756
764
|
async def index(request):
|
|
757
765
|
return 'Hello, World!'
|
|
758
766
|
|
|
759
|
-
In the above example, Microdot issues a standard 200 status code response
|
|
760
|
-
|
|
767
|
+
In the above example, Microdot issues a standard 200 status code response
|
|
768
|
+
indicating a successful request. The body of the response is the
|
|
769
|
+
``'Hello, World!'`` string returned by the function. Microdot includes default
|
|
770
|
+
headers with this response, including the ``Content-Type`` header set to
|
|
771
|
+
``text/plain`` to indicate a response in plain text.
|
|
761
772
|
|
|
762
773
|
The application can provide its own status code as a second value returned from
|
|
763
774
|
the route to override the 200 default. The example below returns a 202 status
|
|
@@ -769,22 +780,30 @@ code::
|
|
|
769
780
|
|
|
770
781
|
The application can also return a third value, a dictionary with additional
|
|
771
782
|
headers that are added to, or replace the default ones included by Microdot.
|
|
772
|
-
The next example returns an HTML response, instead of
|
|
783
|
+
The next example returns an HTML response, instead of the default plain text
|
|
784
|
+
response::
|
|
773
785
|
|
|
774
786
|
@app.get('/')
|
|
775
787
|
async def index(request):
|
|
776
788
|
return '<h1>Hello, World!</h1>', 202, {'Content-Type': 'text/html'}
|
|
777
789
|
|
|
778
|
-
If the application
|
|
779
|
-
the
|
|
780
|
-
|
|
790
|
+
If the application does not need to return a body, then it can omit it and
|
|
791
|
+
have the status code as the first or only returned value::
|
|
792
|
+
|
|
793
|
+
@app.get('/')
|
|
794
|
+
async def index(request):
|
|
795
|
+
return 204
|
|
796
|
+
|
|
797
|
+
Likewise, if the application needs to return a body and custom headers, but
|
|
798
|
+
does not need to change the default status code, then it can return two values,
|
|
799
|
+
omitting the status code::
|
|
781
800
|
|
|
782
801
|
@app.get('/')
|
|
783
802
|
async def index(request):
|
|
784
803
|
return '<h1>Hello, World!</h1>', {'Content-Type': 'text/html'}
|
|
785
804
|
|
|
786
|
-
|
|
787
|
-
containing all the details of the response as a single value.
|
|
805
|
+
Lastly, the application can also return a :class:`Response <microdot.Response>`
|
|
806
|
+
object containing all the details of the response as a single value.
|
|
788
807
|
|
|
789
808
|
JSON Responses
|
|
790
809
|
^^^^^^^^^^^^^^
|
|
@@ -85,7 +85,7 @@ class BasicAuth(BaseAuth):
|
|
|
85
85
|
return None
|
|
86
86
|
return username, password
|
|
87
87
|
|
|
88
|
-
def authentication_error(self, request):
|
|
88
|
+
async def authentication_error(self, request):
|
|
89
89
|
return '', self.error_status, {
|
|
90
90
|
'WWW-Authenticate': '{} realm="{}", charset="{}"'.format(
|
|
91
91
|
self.scheme, self.realm, self.charset)}
|
|
@@ -158,5 +158,5 @@ class TokenAuth(BaseAuth):
|
|
|
158
158
|
"""
|
|
159
159
|
self.error_callback = f
|
|
160
160
|
|
|
161
|
-
def authentication_error(self, request):
|
|
161
|
+
async def authentication_error(self, request):
|
|
162
162
|
abort(self.error_status)
|
|
@@ -554,6 +554,7 @@ class Response:
|
|
|
554
554
|
'json': 'application/json',
|
|
555
555
|
'png': 'image/png',
|
|
556
556
|
'txt': 'text/plain',
|
|
557
|
+
'svg': 'image/svg+xml',
|
|
557
558
|
}
|
|
558
559
|
|
|
559
560
|
send_file_buffer_size = 1024
|
|
@@ -814,6 +815,17 @@ class Response:
|
|
|
814
815
|
|
|
815
816
|
|
|
816
817
|
class URLPattern():
|
|
818
|
+
"""A class that represents the URL pattern for a route.
|
|
819
|
+
|
|
820
|
+
:param url_pattern: The route URL pattern, which can include static and
|
|
821
|
+
dynamic path segments. Dynamic segments are enclosed in
|
|
822
|
+
``<`` and ``>``. The type of the segment can be given
|
|
823
|
+
as a prefix, separated from the name with a colon.
|
|
824
|
+
Supported types are ``string`` (the default),
|
|
825
|
+
``int`` and ``path``. Custom types can be registered
|
|
826
|
+
using the :meth:`URLPattern.register_type` method.
|
|
827
|
+
"""
|
|
828
|
+
|
|
817
829
|
segment_patterns = {
|
|
818
830
|
'string': '/([^/]+)',
|
|
819
831
|
'int': '/(-?\\d+)',
|
|
@@ -823,12 +835,32 @@ class URLPattern():
|
|
|
823
835
|
'int': lambda value: int(value),
|
|
824
836
|
}
|
|
825
837
|
|
|
838
|
+
@classmethod
|
|
839
|
+
def register_type(cls, type_name, pattern='[^/]+', parser=None):
|
|
840
|
+
"""Register a new URL segment type.
|
|
841
|
+
|
|
842
|
+
:param type_name: The name of the segment type to register.
|
|
843
|
+
:param pattern: The regular expression pattern to use when matching
|
|
844
|
+
this segment type. If not given, a default matcher for
|
|
845
|
+
a single path segment is used.
|
|
846
|
+
:param parser: A callable that will be used to parse and transform the
|
|
847
|
+
value of the segment. If omitted, the value is returned
|
|
848
|
+
as a string.
|
|
849
|
+
"""
|
|
850
|
+
cls.segment_patterns[type_name] = '/({})'.format(pattern)
|
|
851
|
+
cls.segment_parsers[type_name] = parser
|
|
852
|
+
|
|
826
853
|
def __init__(self, url_pattern):
|
|
827
854
|
self.url_pattern = url_pattern
|
|
828
855
|
self.segments = []
|
|
829
856
|
self.regex = None
|
|
830
857
|
|
|
831
858
|
def compile(self):
|
|
859
|
+
"""Generate a regular expression for the URL pattern.
|
|
860
|
+
|
|
861
|
+
This method is automatically invoked the first time the URL pattern is
|
|
862
|
+
matched against a path.
|
|
863
|
+
"""
|
|
832
864
|
pattern = ''
|
|
833
865
|
for segment in self.url_pattern.lstrip('/').split('/'):
|
|
834
866
|
if segment and segment[0] == '<':
|
|
@@ -856,12 +888,12 @@ class URLPattern():
|
|
|
856
888
|
self.regex = re.compile('^' + pattern + '$')
|
|
857
889
|
return self.regex
|
|
858
890
|
|
|
859
|
-
@classmethod
|
|
860
|
-
def register_type(cls, type_name, pattern='[^/]+', parser=None):
|
|
861
|
-
cls.segment_patterns[type_name] = '/({})'.format(pattern)
|
|
862
|
-
cls.segment_parsers[type_name] = parser
|
|
863
|
-
|
|
864
891
|
def match(self, path):
|
|
892
|
+
"""Match a path against the URL pattern.
|
|
893
|
+
|
|
894
|
+
Returns a dictionary with the values of all dynamic path segments if a
|
|
895
|
+
matche is found, or ``None`` if the path does not match this pattern.
|
|
896
|
+
"""
|
|
865
897
|
args = {}
|
|
866
898
|
g = (self.regex or self.compile()).match(path)
|
|
867
899
|
if not g:
|
|
@@ -61,7 +61,14 @@ def sse_response(request, event_function, *args, **kwargs):
|
|
|
61
61
|
sse = SSE()
|
|
62
62
|
|
|
63
63
|
async def sse_task_wrapper():
|
|
64
|
-
|
|
64
|
+
try:
|
|
65
|
+
await event_function(request, sse, *args, **kwargs)
|
|
66
|
+
except asyncio.CancelledError: # pragma: no cover
|
|
67
|
+
pass
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
# the SSE task raised an exception so we need to pass it to the
|
|
70
|
+
# main route so that it is re-raised there
|
|
71
|
+
sse.queue.append(exc)
|
|
65
72
|
sse.event.set()
|
|
66
73
|
|
|
67
74
|
task = asyncio.create_task(sse_task_wrapper())
|
|
@@ -79,7 +86,11 @@ def sse_response(request, event_function, *args, **kwargs):
|
|
|
79
86
|
except IndexError:
|
|
80
87
|
await sse.event.wait()
|
|
81
88
|
sse.event.clear()
|
|
82
|
-
if event
|
|
89
|
+
if isinstance(event, Exception):
|
|
90
|
+
# if the event is an exception we re-raise it here so that it
|
|
91
|
+
# can be handled appropriately
|
|
92
|
+
raise event
|
|
93
|
+
elif event is None:
|
|
83
94
|
raise StopAsyncIteration
|
|
84
95
|
return event
|
|
85
96
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
from microdot.microdot import Request, Response, AsyncBytesIO
|
|
2
3
|
|
|
3
4
|
try:
|
|
@@ -32,6 +33,11 @@ class TestResponse:
|
|
|
32
33
|
#: The body of the JSON response, decoded to a dictionary or list. Set
|
|
33
34
|
#: ``Note`` if the response does not have a JSON payload.
|
|
34
35
|
self.json = None
|
|
36
|
+
#: The body of the SSE response, decoded to a list of events, each
|
|
37
|
+
#: given as a dictionary with a ``data`` key and optionally also
|
|
38
|
+
#: ``event`` and ``id`` keys. Set to ``None`` if the response does not
|
|
39
|
+
#: have an SSE payload.
|
|
40
|
+
self.events = None
|
|
35
41
|
|
|
36
42
|
def _initialize_response(self, res):
|
|
37
43
|
self.status_code = res.status_code
|
|
@@ -41,10 +47,13 @@ class TestResponse:
|
|
|
41
47
|
async def _initialize_body(self, res):
|
|
42
48
|
self.body = b''
|
|
43
49
|
iter = res.body_iter()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
body
|
|
47
|
-
|
|
50
|
+
try:
|
|
51
|
+
async for body in iter: # pragma: no branch
|
|
52
|
+
if isinstance(body, str):
|
|
53
|
+
body = body.encode()
|
|
54
|
+
self.body += body
|
|
55
|
+
except asyncio.CancelledError: # pragma: no cover
|
|
56
|
+
pass
|
|
48
57
|
if hasattr(iter, 'aclose'): # pragma: no branch
|
|
49
58
|
await iter.aclose()
|
|
50
59
|
|
|
@@ -60,6 +69,32 @@ class TestResponse:
|
|
|
60
69
|
if content_type.split(';')[0] == 'application/json':
|
|
61
70
|
self.json = json.loads(self.text)
|
|
62
71
|
|
|
72
|
+
def _process_sse_body(self):
|
|
73
|
+
if 'Content-Type' in self.headers: # pragma: no branch
|
|
74
|
+
content_type = self.headers['Content-Type']
|
|
75
|
+
if content_type.split(';')[0] == 'text/event-stream':
|
|
76
|
+
self.events = []
|
|
77
|
+
for sse_event in self.body.split(b'\n\n'):
|
|
78
|
+
data = None
|
|
79
|
+
event = None
|
|
80
|
+
event_id = None
|
|
81
|
+
for line in sse_event.split(b'\n'):
|
|
82
|
+
if line.startswith(b'data:'):
|
|
83
|
+
data = line[5:].strip()
|
|
84
|
+
elif line.startswith(b'event:'):
|
|
85
|
+
event = line[6:].strip().decode()
|
|
86
|
+
elif line.startswith(b'id:'):
|
|
87
|
+
event_id = line[3:].strip().decode()
|
|
88
|
+
if data:
|
|
89
|
+
data_json = None
|
|
90
|
+
try:
|
|
91
|
+
data_json = json.loads(data)
|
|
92
|
+
except ValueError:
|
|
93
|
+
pass
|
|
94
|
+
self.events.append({
|
|
95
|
+
"data": data, "data_json": data_json,
|
|
96
|
+
"event": event, "event_id": event_id})
|
|
97
|
+
|
|
63
98
|
@classmethod
|
|
64
99
|
async def create(cls, res):
|
|
65
100
|
test_res = cls()
|
|
@@ -68,6 +103,7 @@ class TestResponse:
|
|
|
68
103
|
await test_res._initialize_body(res)
|
|
69
104
|
test_res._process_text_body()
|
|
70
105
|
test_res._process_json_body()
|
|
106
|
+
test_res._process_sse_body()
|
|
71
107
|
return test_res
|
|
72
108
|
|
|
73
109
|
|
|
@@ -149,18 +149,18 @@ class WebSocket:
|
|
|
149
149
|
raise WebSocketError('Websocket connection closed')
|
|
150
150
|
fin, opcode, has_mask, length = self._parse_frame_header(header)
|
|
151
151
|
if length == -2:
|
|
152
|
-
length = await self.request.sock[0].
|
|
152
|
+
length = await self.request.sock[0].readexactly(2)
|
|
153
153
|
length = int.from_bytes(length, 'big')
|
|
154
154
|
elif length == -8:
|
|
155
|
-
length = await self.request.sock[0].
|
|
155
|
+
length = await self.request.sock[0].readexactly(8)
|
|
156
156
|
length = int.from_bytes(length, 'big')
|
|
157
157
|
max_allowed_length = Request.max_body_length \
|
|
158
158
|
if self.max_message_length == -1 else self.max_message_length
|
|
159
159
|
if length > max_allowed_length:
|
|
160
160
|
raise WebSocketError('Message too large')
|
|
161
161
|
if has_mask: # pragma: no cover
|
|
162
|
-
mask = await self.request.sock[0].
|
|
163
|
-
payload = await self.request.sock[0].
|
|
162
|
+
mask = await self.request.sock[0].readexactly(4)
|
|
163
|
+
payload = await self.request.sock[0].readexactly(length)
|
|
164
164
|
if has_mask: # pragma: no cover
|
|
165
165
|
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
|
|
166
166
|
return opcode, payload
|
|
@@ -42,3 +42,40 @@ class TestWebSocket(unittest.TestCase):
|
|
|
42
42
|
'data: [42, "foo", "bar"]\n\n'
|
|
43
43
|
'data: foo\n\n'
|
|
44
44
|
'data: foo\n\n'))
|
|
45
|
+
self.assertEqual(len(response.events), 8)
|
|
46
|
+
self.assertEqual(response.events[0], {
|
|
47
|
+
'data': b'foo', 'data_json': None, 'event': None,
|
|
48
|
+
'event_id': None})
|
|
49
|
+
self.assertEqual(response.events[1], {
|
|
50
|
+
'data': b'bar', 'data_json': None, 'event': 'test',
|
|
51
|
+
'event_id': None})
|
|
52
|
+
self.assertEqual(response.events[2], {
|
|
53
|
+
'data': b'bar', 'data_json': None, 'event': 'test',
|
|
54
|
+
'event_id': 'id42'})
|
|
55
|
+
self.assertEqual(response.events[3], {
|
|
56
|
+
'data': b'bar', 'data_json': None, 'event': None,
|
|
57
|
+
'event_id': 'id42'})
|
|
58
|
+
self.assertEqual(response.events[4], {
|
|
59
|
+
'data': b'{"foo": "bar"}', 'data_json': {'foo': 'bar'},
|
|
60
|
+
'event': None, 'event_id': None})
|
|
61
|
+
self.assertEqual(response.events[5], {
|
|
62
|
+
'data': b'[42, "foo", "bar"]', 'data_json': [42, 'foo', 'bar'],
|
|
63
|
+
'event': None, 'event_id': None})
|
|
64
|
+
self.assertEqual(response.events[6], {
|
|
65
|
+
'data': b'foo', 'data_json': None, 'event': None,
|
|
66
|
+
'event_id': None})
|
|
67
|
+
self.assertEqual(response.events[7], {
|
|
68
|
+
'data': b'foo', 'data_json': None, 'event': None,
|
|
69
|
+
'event_id': None})
|
|
70
|
+
|
|
71
|
+
def test_sse_exception(self):
|
|
72
|
+
app = Microdot()
|
|
73
|
+
|
|
74
|
+
@app.route('/sse')
|
|
75
|
+
@with_sse
|
|
76
|
+
async def handle_sse(request, sse):
|
|
77
|
+
await sse.send('foo')
|
|
78
|
+
await sse.send(1 / 0)
|
|
79
|
+
|
|
80
|
+
client = TestClient(app)
|
|
81
|
+
self.assertRaises(ZeroDivisionError, self._run, client.get('/sse'))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|