microdot 2.0.5__tar.gz → 2.0.7__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.0.5/src/microdot.egg-info → microdot-2.0.7}/PKG-INFO +24 -6
- {microdot-2.0.5 → microdot-2.0.7}/README.md +20 -5
- {microdot-2.0.5 → microdot-2.0.7}/docs/extensions.rst +2 -2
- {microdot-2.0.5 → microdot-2.0.7}/docs/intro.rst +33 -6
- {microdot-2.0.5 → microdot-2.0.7}/pyproject.toml +7 -1
- microdot-2.0.7/src/microdot/helpers.py +8 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/microdot.py +14 -6
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/session.py +28 -21
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/sse.py +7 -1
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/test_client.py +32 -9
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/websocket.py +2 -0
- {microdot-2.0.5 → microdot-2.0.7/src/microdot.egg-info}/PKG-INFO +24 -6
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot.egg-info/SOURCES.txt +2 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot.egg-info/dependency_links.txt +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot.egg-info/not-zip-safe +0 -0
- microdot-2.0.7/src/microdot.egg-info/requires.txt +7 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot.egg-info/top_level.txt +0 -0
- microdot-2.0.7/tests/files/test.txt.gz +1 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_microdot.py +21 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_response.py +18 -3
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_session.py +74 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_sse.py +4 -0
- microdot-2.0.5/src/microdot.egg-info/requires.txt +0 -3
- {microdot-2.0.5 → microdot-2.0.7}/LICENSE +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/MANIFEST.in +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/docs/Makefile +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/docs/_static/css/custom.css +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/docs/api.rst +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/docs/conf.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/docs/freezing.rst +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/docs/index.rst +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/docs/make.bat +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/docs/migrating.rst +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/setup.cfg +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/__init__.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/asgi.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/cors.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/jinja.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/utemplate.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/src/microdot/wsgi.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/__init__.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.bin +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.css +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.gif +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.gz +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.html +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.jpg +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.js +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.json +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.png +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/files/test.txt +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/mock_socket.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/templates/hello.jinja.txt +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/templates/hello.utemplate.txt +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/templates/hello_utemplate_txt.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_asgi.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_cors.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_end2end.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_jinja.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_multidict.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_request.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_url_pattern.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_urlencode.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_utemplate.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_websocket.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tests/test_wsgi.py +0 -0
- {microdot-2.0.5 → microdot-2.0.7}/tox.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: microdot
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.7
|
|
4
4
|
Summary: The impossibly small web framework for MicroPython
|
|
5
5
|
Author-email: Miguel Grinberg <miguel.grinberg@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/miguelgrinberg/microdot
|
|
@@ -14,8 +14,11 @@ Classifier: Operating System :: OS Independent
|
|
|
14
14
|
Requires-Python: >=3.8
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: tox; extra == "dev"
|
|
17
19
|
Provides-Extra: docs
|
|
18
20
|
Requires-Dist: sphinx; extra == "docs"
|
|
21
|
+
Requires-Dist: pyjwt; extra == "docs"
|
|
19
22
|
|
|
20
23
|
# microdot
|
|
21
24
|
[](https://github.com/miguelgrinberg/microdot/actions) [](https://codecov.io/gh/miguelgrinberg/microdot)
|
|
@@ -51,10 +54,25 @@ describes the backwards incompatible changes that were made.
|
|
|
51
54
|
|
|
52
55
|
## Resources
|
|
53
56
|
|
|
57
|
+
- [Change Log](https://github.com/miguelgrinberg/microdot/blob/main/CHANGES.md)
|
|
54
58
|
- Documentation
|
|
55
|
-
- [Stable](https://microdot.readthedocs.io/en/stable/)
|
|
56
59
|
- [Latest](https://microdot.readthedocs.io/en/latest/)
|
|
57
|
-
-
|
|
58
|
-
- [Code](https://github.com/miguelgrinberg/microdot/tree/v1)
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
- [Stable (v2)](https://microdot.readthedocs.io/en/stable/)
|
|
61
|
+
- [Legacy (v1)](https://microdot.readthedocs.io/en/v1/) ([Code](https://github.com/miguelgrinberg/microdot/tree/v1))
|
|
62
|
+
|
|
63
|
+
## Roadmap
|
|
64
|
+
|
|
65
|
+
The following features are planned for future releases of Microdot, both for
|
|
66
|
+
MicroPython and CPython:
|
|
67
|
+
|
|
68
|
+
- Support for forms encoded in `multipart/form-data` format
|
|
69
|
+
- Authentication support, similar to [Flask-Login](https://github.com/maxcountryman/flask-login) for Flask
|
|
70
|
+
- OpenAPI integration, similar to [APIFairy](https://github.com/miguelgrinberg/apifairy) for Flask
|
|
71
|
+
|
|
72
|
+
In addition to the above, the following extensions are also under consideration,
|
|
73
|
+
but only for CPython:
|
|
74
|
+
|
|
75
|
+
- Database integration through [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy)
|
|
76
|
+
- Socket.IO support through [python-socketio](https://github.com/miguelgrinberg/python-socketio)
|
|
77
|
+
|
|
78
|
+
Do you have other ideas to propose? Let's [discuss them](https://github.com/miguelgrinberg/microdot/discussions/new?category=ideas)!
|
|
@@ -32,10 +32,25 @@ describes the backwards incompatible changes that were made.
|
|
|
32
32
|
|
|
33
33
|
## Resources
|
|
34
34
|
|
|
35
|
+
- [Change Log](https://github.com/miguelgrinberg/microdot/blob/main/CHANGES.md)
|
|
35
36
|
- Documentation
|
|
36
|
-
- [Stable](https://microdot.readthedocs.io/en/stable/)
|
|
37
37
|
- [Latest](https://microdot.readthedocs.io/en/latest/)
|
|
38
|
-
-
|
|
39
|
-
- [Code](https://github.com/miguelgrinberg/microdot/tree/v1)
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
- [Stable (v2)](https://microdot.readthedocs.io/en/stable/)
|
|
39
|
+
- [Legacy (v1)](https://microdot.readthedocs.io/en/v1/) ([Code](https://github.com/miguelgrinberg/microdot/tree/v1))
|
|
40
|
+
|
|
41
|
+
## Roadmap
|
|
42
|
+
|
|
43
|
+
The following features are planned for future releases of Microdot, both for
|
|
44
|
+
MicroPython and CPython:
|
|
45
|
+
|
|
46
|
+
- Support for forms encoded in `multipart/form-data` format
|
|
47
|
+
- Authentication support, similar to [Flask-Login](https://github.com/maxcountryman/flask-login) for Flask
|
|
48
|
+
- OpenAPI integration, similar to [APIFairy](https://github.com/miguelgrinberg/apifairy) for Flask
|
|
49
|
+
|
|
50
|
+
In addition to the above, the following extensions are also under consideration,
|
|
51
|
+
but only for CPython:
|
|
52
|
+
|
|
53
|
+
- Database integration through [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy)
|
|
54
|
+
- Socket.IO support through [python-socketio](https://github.com/miguelgrinberg/python-socketio)
|
|
55
|
+
|
|
56
|
+
Do you have other ideas to propose? Let's [discuss them](https://github.com/miguelgrinberg/microdot/discussions/new?category=ideas)!
|
|
@@ -286,7 +286,7 @@ Cross-Origin Resource Sharing (CORS)
|
|
|
286
286
|
- | None
|
|
287
287
|
|
|
288
288
|
* - Examples
|
|
289
|
-
- | `
|
|
289
|
+
- | `app.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/cors/app.py>`_
|
|
290
290
|
|
|
291
291
|
The CORS extension provides support for `Cross-Origin Resource Sharing
|
|
292
292
|
(CORS) <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_. CORS is a
|
|
@@ -363,7 +363,7 @@ Using an ASGI Web Server
|
|
|
363
363
|
- | `asgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/asgi.py>`_
|
|
364
364
|
|
|
365
365
|
* - Required external dependencies
|
|
366
|
-
- | An ASGI web server, such as `Uvicorn <https://uvicorn.org/>`_.
|
|
366
|
+
- | An ASGI web server, such as `Uvicorn <https://www.uvicorn.org/>`_.
|
|
367
367
|
|
|
368
368
|
* - Examples
|
|
369
369
|
- | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_asgi.py>`_
|
|
@@ -82,8 +82,34 @@ handler functions can be defined as ``async def`` or ``def`` functions, but
|
|
|
82
82
|
``async def`` functions are recommended for performance.
|
|
83
83
|
|
|
84
84
|
The :func:`run() <microdot.Microdot.run>` method starts the application's web
|
|
85
|
-
server on port 5000 by default
|
|
86
|
-
connections from clients.
|
|
85
|
+
server on port 5000 by default, and creates its own asynchronous loop. This
|
|
86
|
+
method blocks while it waits for connections from clients.
|
|
87
|
+
|
|
88
|
+
For some applications it may be necessary to run the web server alongside other
|
|
89
|
+
asynchronous tasks, on an already running loop. In that case, instead of
|
|
90
|
+
``app.run()`` the web server can be started by invoking the
|
|
91
|
+
:func:`start_server() <microdot.Microdot.start_server>` coroutine as shown in
|
|
92
|
+
the following example::
|
|
93
|
+
|
|
94
|
+
import asyncio
|
|
95
|
+
from microdot import Microdot
|
|
96
|
+
|
|
97
|
+
app = Microdot()
|
|
98
|
+
|
|
99
|
+
@app.route('/')
|
|
100
|
+
async def index(request):
|
|
101
|
+
return 'Hello, world!'
|
|
102
|
+
|
|
103
|
+
async def main():
|
|
104
|
+
# start the server in a background task
|
|
105
|
+
server = asyncio.create_task(app.start_server())
|
|
106
|
+
|
|
107
|
+
# ... do other asynchronous work here ...
|
|
108
|
+
|
|
109
|
+
# cleanup before ending the application
|
|
110
|
+
await server
|
|
111
|
+
|
|
112
|
+
asyncio.run(main())
|
|
87
113
|
|
|
88
114
|
Running with CPython
|
|
89
115
|
^^^^^^^^^^^^^^^^^^^^
|
|
@@ -92,7 +118,7 @@ Running with CPython
|
|
|
92
118
|
:align: left
|
|
93
119
|
|
|
94
120
|
* - Required Microdot source files
|
|
95
|
-
- | `microdot.py <https://github.com/miguelgrinberg/microdot/
|
|
121
|
+
- | `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
|
|
96
122
|
|
|
97
123
|
* - Required external dependencies
|
|
98
124
|
- | None
|
|
@@ -118,7 +144,7 @@ Running with MicroPython
|
|
|
118
144
|
:align: left
|
|
119
145
|
|
|
120
146
|
* - Required Microdot source files
|
|
121
|
-
- | `microdot.py <https://github.com/miguelgrinberg/microdot/
|
|
147
|
+
- | `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
|
|
122
148
|
|
|
123
149
|
* - Required external dependencies
|
|
124
150
|
- | None
|
|
@@ -145,8 +171,9 @@ changed by passing the ``port`` argument to the ``run()`` method.
|
|
|
145
171
|
Web Server Configuration
|
|
146
172
|
^^^^^^^^^^^^^^^^^^^^^^^^
|
|
147
173
|
|
|
148
|
-
The :func:`run() <microdot.Microdot.run>`
|
|
149
|
-
|
|
174
|
+
The :func:`run() <microdot.Microdot.run>` and
|
|
175
|
+
:func:`start_server() <microdot.Microdot.start_server>` methods support a few
|
|
176
|
+
arguments to configure the web server.
|
|
150
177
|
|
|
151
178
|
- ``port``: The port number to listen on. Pass the desired port number in this
|
|
152
179
|
argument to use a port different than the default of 5000. For example::
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "microdot"
|
|
3
|
-
version = "2.0.
|
|
3
|
+
version = "2.0.7"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
|
|
6
6
|
]
|
|
@@ -14,6 +14,8 @@ classifiers = [
|
|
|
14
14
|
"Operating System :: OS Independent",
|
|
15
15
|
]
|
|
16
16
|
requires-python = ">=3.8"
|
|
17
|
+
dependencies = [
|
|
18
|
+
]
|
|
17
19
|
|
|
18
20
|
[project.readme]
|
|
19
21
|
file = "README.md"
|
|
@@ -24,8 +26,12 @@ Homepage = "https://github.com/miguelgrinberg/microdot"
|
|
|
24
26
|
"Bug Tracker" = "https://github.com/miguelgrinberg/microdot/issues"
|
|
25
27
|
|
|
26
28
|
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"tox",
|
|
31
|
+
]
|
|
27
32
|
docs = [
|
|
28
33
|
"sphinx",
|
|
34
|
+
"pyjwt",
|
|
29
35
|
]
|
|
30
36
|
|
|
31
37
|
[tool.setuptools]
|
|
@@ -598,7 +598,7 @@ class Response:
|
|
|
598
598
|
else: # pragma: no cover
|
|
599
599
|
http_cookie += '; Expires=' + time.strftime(
|
|
600
600
|
'%a, %d %b %Y %H:%M:%S GMT', expires.timetuple())
|
|
601
|
-
if max_age:
|
|
601
|
+
if max_age is not None:
|
|
602
602
|
http_cookie += '; Max-Age=' + str(max_age)
|
|
603
603
|
if secure:
|
|
604
604
|
http_cookie += '; Secure'
|
|
@@ -616,10 +616,10 @@ class Response:
|
|
|
616
616
|
|
|
617
617
|
:param cookie: The cookie's name.
|
|
618
618
|
:param kwargs: Any cookie opens and flags supported by
|
|
619
|
-
``set_cookie()`` except ``expires``.
|
|
619
|
+
``set_cookie()`` except ``expires`` and ``max_age``.
|
|
620
620
|
"""
|
|
621
621
|
self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT',
|
|
622
|
-
**kwargs)
|
|
622
|
+
max_age=0, **kwargs)
|
|
623
623
|
|
|
624
624
|
def complete(self):
|
|
625
625
|
if isinstance(self.body, bytes) and \
|
|
@@ -774,7 +774,10 @@ class Response:
|
|
|
774
774
|
first.
|
|
775
775
|
"""
|
|
776
776
|
if content_type is None:
|
|
777
|
-
|
|
777
|
+
if compressed and filename.endswith('.gz'):
|
|
778
|
+
ext = filename[:-3].split('.')[-1]
|
|
779
|
+
else:
|
|
780
|
+
ext = filename.split('.')[-1]
|
|
778
781
|
if ext in Response.types_map:
|
|
779
782
|
content_type = Response.types_map[ext]
|
|
780
783
|
else:
|
|
@@ -1191,7 +1194,7 @@ class Microdot:
|
|
|
1191
1194
|
Example::
|
|
1192
1195
|
|
|
1193
1196
|
import asyncio
|
|
1194
|
-
from
|
|
1197
|
+
from microdot import Microdot
|
|
1195
1198
|
|
|
1196
1199
|
app = Microdot()
|
|
1197
1200
|
|
|
@@ -1268,7 +1271,7 @@ class Microdot:
|
|
|
1268
1271
|
|
|
1269
1272
|
Example::
|
|
1270
1273
|
|
|
1271
|
-
from
|
|
1274
|
+
from microdot import Microdot
|
|
1272
1275
|
|
|
1273
1276
|
app = Microdot()
|
|
1274
1277
|
|
|
@@ -1366,7 +1369,12 @@ class Microdot:
|
|
|
1366
1369
|
if res is None:
|
|
1367
1370
|
res = await invoke_handler(
|
|
1368
1371
|
f, req, **req.url_args)
|
|
1372
|
+
if isinstance(res, int):
|
|
1373
|
+
res = '', res
|
|
1369
1374
|
if isinstance(res, tuple):
|
|
1375
|
+
if isinstance(res[0], int):
|
|
1376
|
+
res = ('', res[0],
|
|
1377
|
+
res[1] if len(res) > 1 else {})
|
|
1370
1378
|
body = res[0]
|
|
1371
1379
|
if isinstance(res[1], int):
|
|
1372
1380
|
status_code = res[1]
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import jwt
|
|
2
2
|
from microdot.microdot import invoke_handler
|
|
3
|
-
|
|
4
|
-
secret_key = None
|
|
3
|
+
from microdot.helpers import wraps
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
class SessionDict(dict):
|
|
@@ -30,14 +29,21 @@ class Session:
|
|
|
30
29
|
"""
|
|
31
30
|
secret_key = None
|
|
32
31
|
|
|
33
|
-
def __init__(self, app=None, secret_key=None):
|
|
32
|
+
def __init__(self, app=None, secret_key=None, cookie_options=None):
|
|
34
33
|
self.secret_key = secret_key
|
|
34
|
+
self.cookie_options = cookie_options or {}
|
|
35
35
|
if app is not None:
|
|
36
36
|
self.initialize(app)
|
|
37
37
|
|
|
38
|
-
def initialize(self, app, secret_key=None):
|
|
38
|
+
def initialize(self, app, secret_key=None, cookie_options=None):
|
|
39
39
|
if secret_key is not None:
|
|
40
40
|
self.secret_key = secret_key
|
|
41
|
+
if cookie_options is not None:
|
|
42
|
+
self.cookie_options = cookie_options
|
|
43
|
+
if 'path' not in self.cookie_options:
|
|
44
|
+
self.cookie_options['path'] = '/'
|
|
45
|
+
if 'http_only' not in self.cookie_options:
|
|
46
|
+
self.cookie_options['http_only'] = True
|
|
41
47
|
app._session = self
|
|
42
48
|
|
|
43
49
|
def get(self, request):
|
|
@@ -57,13 +63,7 @@ class Session:
|
|
|
57
63
|
if session is None:
|
|
58
64
|
request.g._session = SessionDict(request, {})
|
|
59
65
|
return request.g._session
|
|
60
|
-
|
|
61
|
-
session = jwt.decode(session, self.secret_key,
|
|
62
|
-
algorithms=['HS256'])
|
|
63
|
-
except jwt.exceptions.PyJWTError: # pragma: no cover
|
|
64
|
-
request.g._session = SessionDict(request, {})
|
|
65
|
-
else:
|
|
66
|
-
request.g._session = SessionDict(request, session)
|
|
66
|
+
request.g._session = SessionDict(request, self.decode(session))
|
|
67
67
|
return request.g._session
|
|
68
68
|
|
|
69
69
|
def update(self, request, session):
|
|
@@ -89,12 +89,12 @@ class Session:
|
|
|
89
89
|
if not self.secret_key:
|
|
90
90
|
raise ValueError('The session secret key is not configured')
|
|
91
91
|
|
|
92
|
-
encoded_session =
|
|
93
|
-
algorithm='HS256')
|
|
92
|
+
encoded_session = self.encode(session)
|
|
94
93
|
|
|
95
94
|
@request.after_request
|
|
96
95
|
def _update_session(request, response):
|
|
97
|
-
response.set_cookie('session', encoded_session,
|
|
96
|
+
response.set_cookie('session', encoded_session,
|
|
97
|
+
**self.cookie_options)
|
|
98
98
|
return response
|
|
99
99
|
|
|
100
100
|
def delete(self, request):
|
|
@@ -117,10 +117,21 @@ class Session:
|
|
|
117
117
|
"""
|
|
118
118
|
@request.after_request
|
|
119
119
|
def _delete_session(request, response):
|
|
120
|
-
response.
|
|
121
|
-
expires='Thu, 01 Jan 1970 00:00:01 GMT')
|
|
120
|
+
response.delete_cookie('session', **self.cookie_options)
|
|
122
121
|
return response
|
|
123
122
|
|
|
123
|
+
def encode(self, payload, secret_key=None):
|
|
124
|
+
return jwt.encode(payload, secret_key or self.secret_key,
|
|
125
|
+
algorithm='HS256')
|
|
126
|
+
|
|
127
|
+
def decode(self, session, secret_key=None):
|
|
128
|
+
try:
|
|
129
|
+
payload = jwt.decode(session, secret_key or self.secret_key,
|
|
130
|
+
algorithms=['HS256'])
|
|
131
|
+
except jwt.exceptions.PyJWTError: # pragma: no cover
|
|
132
|
+
return {}
|
|
133
|
+
return payload
|
|
134
|
+
|
|
124
135
|
|
|
125
136
|
def with_session(f):
|
|
126
137
|
"""Decorator that passes the user session to the route handler.
|
|
@@ -136,13 +147,9 @@ def with_session(f):
|
|
|
136
147
|
Note that the decorator does not save the session. To update the session,
|
|
137
148
|
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
|
|
138
149
|
"""
|
|
150
|
+
@wraps(f)
|
|
139
151
|
async def wrapper(request, *args, **kwargs):
|
|
140
152
|
return await invoke_handler(
|
|
141
153
|
f, request, request.app._session.get(request), *args, **kwargs)
|
|
142
154
|
|
|
143
|
-
for attr in ['__name__', '__doc__', '__module__', '__qualname__']:
|
|
144
|
-
try:
|
|
145
|
-
setattr(wrapper, attr, getattr(f, attr))
|
|
146
|
-
except AttributeError: # pragma: no cover
|
|
147
|
-
pass
|
|
148
155
|
return wrapper
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
+
from microdot.helpers import wraps
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
class SSE:
|
|
@@ -12,7 +13,7 @@ class SSE:
|
|
|
12
13
|
self.event = asyncio.Event()
|
|
13
14
|
self.queue = []
|
|
14
15
|
|
|
15
|
-
async def send(self, data, event=None):
|
|
16
|
+
async def send(self, data, event=None, event_id=None):
|
|
16
17
|
"""Send an event to the client.
|
|
17
18
|
|
|
18
19
|
:param data: the data to send. It can be given as a string, bytes, dict
|
|
@@ -20,6 +21,8 @@ class SSE:
|
|
|
20
21
|
Any other types are converted to string before sending.
|
|
21
22
|
:param event: an optional event name, to send along with the data. If
|
|
22
23
|
given, it must be a string.
|
|
24
|
+
:param event_id: an optional event id, to send along with the data. If
|
|
25
|
+
given, it must be a string.
|
|
23
26
|
"""
|
|
24
27
|
if isinstance(data, (dict, list)):
|
|
25
28
|
data = json.dumps(data).encode()
|
|
@@ -28,6 +31,8 @@ class SSE:
|
|
|
28
31
|
elif not isinstance(data, bytes):
|
|
29
32
|
data = str(data).encode()
|
|
30
33
|
data = b'data: ' + data + b'\n\n'
|
|
34
|
+
if event_id:
|
|
35
|
+
data = b'id: ' + event_id.encode() + b'\n' + data
|
|
31
36
|
if event:
|
|
32
37
|
data = b'event: ' + event.encode() + b'\n' + data
|
|
33
38
|
self.queue.append(data)
|
|
@@ -99,6 +104,7 @@ def with_sse(f):
|
|
|
99
104
|
# send a named event
|
|
100
105
|
await sse.send('hello', event='greeting')
|
|
101
106
|
"""
|
|
107
|
+
@wraps(f)
|
|
102
108
|
async def sse_handler(request, *args, **kwargs):
|
|
103
109
|
return sse_response(request, f, *args, **kwargs)
|
|
104
110
|
|
|
@@ -77,7 +77,7 @@ class TestClient:
|
|
|
77
77
|
The following example shows how to create a test client for an application
|
|
78
78
|
and send a test request::
|
|
79
79
|
|
|
80
|
-
from
|
|
80
|
+
from microdot import Microdot
|
|
81
81
|
|
|
82
82
|
app = Microdot()
|
|
83
83
|
|
|
@@ -112,9 +112,13 @@ class TestClient:
|
|
|
112
112
|
headers['Host'] = 'example.com:1234'
|
|
113
113
|
return body, headers
|
|
114
114
|
|
|
115
|
-
def _process_cookies(self, headers):
|
|
115
|
+
def _process_cookies(self, path, headers):
|
|
116
116
|
cookies = ''
|
|
117
117
|
for name, value in self.cookies.items():
|
|
118
|
+
if isinstance(value, tuple):
|
|
119
|
+
value, cookie_path = value
|
|
120
|
+
if not path.startswith(cookie_path):
|
|
121
|
+
continue
|
|
118
122
|
if cookies:
|
|
119
123
|
cookies += '; '
|
|
120
124
|
cookies += name + '=' + value
|
|
@@ -123,7 +127,7 @@ class TestClient:
|
|
|
123
127
|
headers['Cookie'] += '; ' + cookies
|
|
124
128
|
else:
|
|
125
129
|
headers['Cookie'] = cookies
|
|
126
|
-
return
|
|
130
|
+
return headers
|
|
127
131
|
|
|
128
132
|
def _render_request(self, method, path, headers, body):
|
|
129
133
|
request_bytes = '{method} {path} HTTP/1.0\n'.format(
|
|
@@ -139,26 +143,45 @@ class TestClient:
|
|
|
139
143
|
for cookie in cookies:
|
|
140
144
|
cookie_name, cookie_value = cookie.split('=', 1)
|
|
141
145
|
cookie_options = cookie_value.split(';')
|
|
146
|
+
path = '/'
|
|
142
147
|
delete = False
|
|
143
148
|
for option in cookie_options[1:]:
|
|
144
|
-
|
|
145
|
-
|
|
149
|
+
option = option.strip().lower()
|
|
150
|
+
if option.startswith(
|
|
151
|
+
'max-age='): # pragma: no cover
|
|
152
|
+
_, age = option.split('=', 1)
|
|
153
|
+
try:
|
|
154
|
+
age = int(age)
|
|
155
|
+
except ValueError: # pragma: no cover
|
|
156
|
+
age = 0
|
|
157
|
+
if age <= 0:
|
|
158
|
+
delete = True
|
|
159
|
+
elif option.startswith('expires='):
|
|
160
|
+
_, e = option.split('=', 1)
|
|
146
161
|
# this is a very limited parser for cookie expiry
|
|
147
162
|
# that only detects a cookie deletion request when
|
|
148
163
|
# the date is 1/1/1970
|
|
149
164
|
if '1 jan 1970' in e.lower(): # pragma: no branch
|
|
150
165
|
delete = True
|
|
151
|
-
|
|
166
|
+
elif option.startswith('path='):
|
|
167
|
+
_, path = option.split('=', 1)
|
|
152
168
|
if delete:
|
|
153
169
|
if cookie_name in self.cookies: # pragma: no branch
|
|
154
|
-
|
|
170
|
+
cookie_path = self.cookies[cookie_name][1] \
|
|
171
|
+
if isinstance(self.cookies[cookie_name], tuple) \
|
|
172
|
+
else '/'
|
|
173
|
+
if path == cookie_path:
|
|
174
|
+
del self.cookies[cookie_name]
|
|
155
175
|
else:
|
|
156
|
-
|
|
176
|
+
if path == '/':
|
|
177
|
+
self.cookies[cookie_name] = cookie_options[0]
|
|
178
|
+
else:
|
|
179
|
+
self.cookies[cookie_name] = (cookie_options[0], path)
|
|
157
180
|
|
|
158
181
|
async def request(self, method, path, headers=None, body=None, sock=None):
|
|
159
182
|
headers = headers or {}
|
|
160
183
|
body, headers = self._process_body(body, headers)
|
|
161
|
-
|
|
184
|
+
headers = self._process_cookies(path, headers)
|
|
162
185
|
request_bytes = self._render_request(method, path, headers, body)
|
|
163
186
|
if sock:
|
|
164
187
|
reader = sock[0]
|
|
@@ -2,6 +2,7 @@ import binascii
|
|
|
2
2
|
import hashlib
|
|
3
3
|
from microdot import Request, Response
|
|
4
4
|
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
|
|
5
|
+
from microdot.helpers import wraps
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class WebSocketError(Exception):
|
|
@@ -192,6 +193,7 @@ async def websocket_upgrade(request):
|
|
|
192
193
|
|
|
193
194
|
|
|
194
195
|
def websocket_wrapper(f, upgrade_function):
|
|
196
|
+
@wraps(f)
|
|
195
197
|
async def wrapper(request, *args, **kwargs):
|
|
196
198
|
ws = await upgrade_function(request)
|
|
197
199
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: microdot
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.7
|
|
4
4
|
Summary: The impossibly small web framework for MicroPython
|
|
5
5
|
Author-email: Miguel Grinberg <miguel.grinberg@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/miguelgrinberg/microdot
|
|
@@ -14,8 +14,11 @@ Classifier: Operating System :: OS Independent
|
|
|
14
14
|
Requires-Python: >=3.8
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: tox; extra == "dev"
|
|
17
19
|
Provides-Extra: docs
|
|
18
20
|
Requires-Dist: sphinx; extra == "docs"
|
|
21
|
+
Requires-Dist: pyjwt; extra == "docs"
|
|
19
22
|
|
|
20
23
|
# microdot
|
|
21
24
|
[](https://github.com/miguelgrinberg/microdot/actions) [](https://codecov.io/gh/miguelgrinberg/microdot)
|
|
@@ -51,10 +54,25 @@ describes the backwards incompatible changes that were made.
|
|
|
51
54
|
|
|
52
55
|
## Resources
|
|
53
56
|
|
|
57
|
+
- [Change Log](https://github.com/miguelgrinberg/microdot/blob/main/CHANGES.md)
|
|
54
58
|
- Documentation
|
|
55
|
-
- [Stable](https://microdot.readthedocs.io/en/stable/)
|
|
56
59
|
- [Latest](https://microdot.readthedocs.io/en/latest/)
|
|
57
|
-
-
|
|
58
|
-
- [Code](https://github.com/miguelgrinberg/microdot/tree/v1)
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
- [Stable (v2)](https://microdot.readthedocs.io/en/stable/)
|
|
61
|
+
- [Legacy (v1)](https://microdot.readthedocs.io/en/v1/) ([Code](https://github.com/miguelgrinberg/microdot/tree/v1))
|
|
62
|
+
|
|
63
|
+
## Roadmap
|
|
64
|
+
|
|
65
|
+
The following features are planned for future releases of Microdot, both for
|
|
66
|
+
MicroPython and CPython:
|
|
67
|
+
|
|
68
|
+
- Support for forms encoded in `multipart/form-data` format
|
|
69
|
+
- Authentication support, similar to [Flask-Login](https://github.com/maxcountryman/flask-login) for Flask
|
|
70
|
+
- OpenAPI integration, similar to [APIFairy](https://github.com/miguelgrinberg/apifairy) for Flask
|
|
71
|
+
|
|
72
|
+
In addition to the above, the following extensions are also under consideration,
|
|
73
|
+
but only for CPython:
|
|
74
|
+
|
|
75
|
+
- Database integration through [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy)
|
|
76
|
+
- Socket.IO support through [python-socketio](https://github.com/miguelgrinberg/python-socketio)
|
|
77
|
+
|
|
78
|
+
Do you have other ideas to propose? Let's [discuss them](https://github.com/miguelgrinberg/microdot/discussions/new?category=ideas)!
|
|
@@ -16,6 +16,7 @@ docs/_static/css/custom.css
|
|
|
16
16
|
src/microdot/__init__.py
|
|
17
17
|
src/microdot/asgi.py
|
|
18
18
|
src/microdot/cors.py
|
|
19
|
+
src/microdot/helpers.py
|
|
19
20
|
src/microdot/jinja.py
|
|
20
21
|
src/microdot/microdot.py
|
|
21
22
|
src/microdot/session.py
|
|
@@ -57,6 +58,7 @@ tests/files/test.js
|
|
|
57
58
|
tests/files/test.json
|
|
58
59
|
tests/files/test.png
|
|
59
60
|
tests/files/test.txt
|
|
61
|
+
tests/files/test.txt.gz
|
|
60
62
|
tests/templates/hello.jinja.txt
|
|
61
63
|
tests/templates/hello.utemplate.txt
|
|
62
64
|
tests/templates/hello_utemplate_txt.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
foo
|
|
@@ -203,6 +203,7 @@ class TestMicrodot(unittest.TestCase):
|
|
|
203
203
|
req.cookies['one'] + req.cookies['two'] + req.cookies['three'])
|
|
204
204
|
res.set_cookie('four', '4')
|
|
205
205
|
res.delete_cookie('two', path='/')
|
|
206
|
+
res.delete_cookie('one', path='/bad')
|
|
206
207
|
return res
|
|
207
208
|
|
|
208
209
|
client = TestClient(app, cookies={'one': '1', 'two': '2'})
|
|
@@ -273,6 +274,14 @@ class TestMicrodot(unittest.TestCase):
|
|
|
273
274
|
return '<p>four</p>', 202, \
|
|
274
275
|
{'Content-Type': 'text/html; charset=UTF-8'}
|
|
275
276
|
|
|
277
|
+
@app.route('/status')
|
|
278
|
+
def five(req):
|
|
279
|
+
return 202
|
|
280
|
+
|
|
281
|
+
@app.route('/status-headers')
|
|
282
|
+
def six(req):
|
|
283
|
+
return 202, {'Content-Type': 'text/html; charset=UTF-8'}
|
|
284
|
+
|
|
276
285
|
client = TestClient(app)
|
|
277
286
|
|
|
278
287
|
res = self._run(client.get('/body'))
|
|
@@ -298,6 +307,18 @@ class TestMicrodot(unittest.TestCase):
|
|
|
298
307
|
'text/html; charset=UTF-8')
|
|
299
308
|
self.assertEqual(res.text, '<p>four</p>')
|
|
300
309
|
|
|
310
|
+
res = self._run(client.get('/status'))
|
|
311
|
+
self.assertEqual(res.text, '')
|
|
312
|
+
self.assertEqual(res.status_code, 202)
|
|
313
|
+
self.assertEqual(res.headers['Content-Type'],
|
|
314
|
+
'text/plain; charset=UTF-8')
|
|
315
|
+
|
|
316
|
+
res = self._run(client.get('/status-headers'))
|
|
317
|
+
self.assertEqual(res.text, '')
|
|
318
|
+
self.assertEqual(res.status_code, 202)
|
|
319
|
+
self.assertEqual(res.headers['Content-Type'],
|
|
320
|
+
'text/html; charset=UTF-8')
|
|
321
|
+
|
|
301
322
|
def test_before_after_request(self):
|
|
302
323
|
app = Microdot()
|
|
303
324
|
|
|
@@ -136,10 +136,10 @@ class TestResponse(unittest.TestCase):
|
|
|
136
136
|
self.assertTrue(fd.response.endswith(b'\r\n\r\nfoobar'))
|
|
137
137
|
|
|
138
138
|
def test_create_from_other(self):
|
|
139
|
-
res = Response(
|
|
139
|
+
res = Response(23.7)
|
|
140
140
|
self.assertEqual(res.status_code, 200)
|
|
141
141
|
self.assertEqual(res.headers, {})
|
|
142
|
-
self.assertEqual(res.body,
|
|
142
|
+
self.assertEqual(res.body, 23.7)
|
|
143
143
|
|
|
144
144
|
def test_create_with_status_code(self):
|
|
145
145
|
res = Response('not found', 404)
|
|
@@ -193,6 +193,7 @@ class TestResponse(unittest.TestCase):
|
|
|
193
193
|
expires='Tue, 05 Nov 2019 02:23:54 GMT', max_age=123,
|
|
194
194
|
secure=True, http_only=True)
|
|
195
195
|
res.delete_cookie('foo8', http_only=True)
|
|
196
|
+
res.delete_cookie('foo9', path='/s')
|
|
196
197
|
self.assertEqual(res.headers, {'Set-Cookie': [
|
|
197
198
|
'foo1=bar1',
|
|
198
199
|
'foo2=bar2; Path=/; Partitioned',
|
|
@@ -203,7 +204,10 @@ class TestResponse(unittest.TestCase):
|
|
|
203
204
|
'foo7=bar7; Path=/foo; Domain=example.com:1234; '
|
|
204
205
|
'Expires=Tue, 05 Nov 2019 02:23:54 GMT; Max-Age=123; Secure; '
|
|
205
206
|
'HttpOnly',
|
|
206
|
-
'foo8=; Expires=Thu, 01 Jan 1970 00:00:01 GMT;
|
|
207
|
+
('foo8=; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; '
|
|
208
|
+
'HttpOnly'),
|
|
209
|
+
('foo9=; Path=/s; Expires=Thu, 01 Jan 1970 00:00:01 GMT; '
|
|
210
|
+
'Max-Age=0'),
|
|
207
211
|
]})
|
|
208
212
|
|
|
209
213
|
def test_redirect(self):
|
|
@@ -276,6 +280,17 @@ class TestResponse(unittest.TestCase):
|
|
|
276
280
|
'application/octet-stream')
|
|
277
281
|
self.assertEqual(res.headers['Content-Encoding'], 'gzip')
|
|
278
282
|
|
|
283
|
+
def test_send_file_gzip_handling(self):
|
|
284
|
+
res = Response.send_file('tests/files/test.txt.gz')
|
|
285
|
+
self.assertEqual(res.status_code, 200)
|
|
286
|
+
self.assertEqual(res.headers['Content-Type'],
|
|
287
|
+
'application/octet-stream')
|
|
288
|
+
|
|
289
|
+
res = Response.send_file('tests/files/test.txt.gz', compressed=True)
|
|
290
|
+
self.assertEqual(res.status_code, 200)
|
|
291
|
+
self.assertEqual(res.headers['Content-Type'], 'text/plain')
|
|
292
|
+
self.assertEqual(res.headers['Content-Encoding'], 'gzip')
|
|
293
|
+
|
|
279
294
|
def test_default_content_type(self):
|
|
280
295
|
original_content_type = Response.default_content_type
|
|
281
296
|
res = Response('foo')
|
|
@@ -82,3 +82,77 @@ class TestSession(unittest.TestCase):
|
|
|
82
82
|
|
|
83
83
|
res = self._run(client.get('/'))
|
|
84
84
|
self.assertEqual(res.status_code, 200)
|
|
85
|
+
|
|
86
|
+
def test_session_default_path(self):
|
|
87
|
+
app = Microdot()
|
|
88
|
+
Session(app, secret_key='some-other-secret')
|
|
89
|
+
client = TestClient(app)
|
|
90
|
+
|
|
91
|
+
@app.get('/')
|
|
92
|
+
@with_session
|
|
93
|
+
def index(req, session):
|
|
94
|
+
session['foo'] = 'bar'
|
|
95
|
+
session.save()
|
|
96
|
+
return ''
|
|
97
|
+
|
|
98
|
+
@app.get('/child')
|
|
99
|
+
@with_session
|
|
100
|
+
def child(req, session):
|
|
101
|
+
return str(session.get('foo'))
|
|
102
|
+
|
|
103
|
+
@app.get('/delete')
|
|
104
|
+
@with_session
|
|
105
|
+
def delete(req, session):
|
|
106
|
+
session.delete()
|
|
107
|
+
return ''
|
|
108
|
+
|
|
109
|
+
res = self._run(client.get('/'))
|
|
110
|
+
self.assertEqual(res.status_code, 200)
|
|
111
|
+
res = self._run(client.get('/child'))
|
|
112
|
+
self.assertEqual(res.text, 'bar')
|
|
113
|
+
res = self._run(client.get('/delete'))
|
|
114
|
+
res = self._run(client.get('/child'))
|
|
115
|
+
self.assertEqual(res.text, 'None')
|
|
116
|
+
|
|
117
|
+
def test_session_custom_path(self):
|
|
118
|
+
app = Microdot()
|
|
119
|
+
session_ext = Session()
|
|
120
|
+
session_ext.initialize(app, secret_key='some-other-secret',
|
|
121
|
+
cookie_options={'path': '/child',
|
|
122
|
+
'http_only': False})
|
|
123
|
+
client = TestClient(app)
|
|
124
|
+
|
|
125
|
+
@app.get('/')
|
|
126
|
+
@with_session
|
|
127
|
+
def index(req, session):
|
|
128
|
+
return str(session.get('foo'))
|
|
129
|
+
|
|
130
|
+
@app.get('/child')
|
|
131
|
+
@with_session
|
|
132
|
+
def child(req, session):
|
|
133
|
+
session['foo'] = 'bar'
|
|
134
|
+
session.save()
|
|
135
|
+
return ''
|
|
136
|
+
|
|
137
|
+
@app.get('/child/foo')
|
|
138
|
+
@with_session
|
|
139
|
+
def foo(req, session):
|
|
140
|
+
return str(session.get('foo'))
|
|
141
|
+
|
|
142
|
+
@app.get('/child/delete')
|
|
143
|
+
@with_session
|
|
144
|
+
def delete(req, session):
|
|
145
|
+
session.delete()
|
|
146
|
+
return ''
|
|
147
|
+
|
|
148
|
+
res = self._run(client.get('/child'))
|
|
149
|
+
self.assertEqual(res.status_code, 200)
|
|
150
|
+
res = self._run(client.get('/'))
|
|
151
|
+
self.assertEqual(res.text, 'None')
|
|
152
|
+
res = self._run(client.get('/child/foo'))
|
|
153
|
+
self.assertEqual(res.text, 'bar')
|
|
154
|
+
res = self._run(client.get('/child/delete'))
|
|
155
|
+
res = self._run(client.get('/'))
|
|
156
|
+
self.assertEqual(res.text, 'None')
|
|
157
|
+
res = self._run(client.get('/child/foo'))
|
|
158
|
+
self.assertEqual(res.text, 'None')
|
|
@@ -23,6 +23,8 @@ class TestWebSocket(unittest.TestCase):
|
|
|
23
23
|
async def handle_sse(request, sse):
|
|
24
24
|
await sse.send('foo')
|
|
25
25
|
await sse.send('bar', event='test')
|
|
26
|
+
await sse.send('bar', event='test', event_id='id42')
|
|
27
|
+
await sse.send('bar', event_id='id42')
|
|
26
28
|
await sse.send({'foo': 'bar'})
|
|
27
29
|
await sse.send([42, 'foo', 'bar'])
|
|
28
30
|
await sse.send(ValueError('foo'))
|
|
@@ -34,6 +36,8 @@ class TestWebSocket(unittest.TestCase):
|
|
|
34
36
|
self.assertEqual(response.headers['Content-Type'], 'text/event-stream')
|
|
35
37
|
self.assertEqual(response.text, ('data: foo\n\n'
|
|
36
38
|
'event: test\ndata: bar\n\n'
|
|
39
|
+
'event: test\nid: id42\ndata: bar\n\n'
|
|
40
|
+
'id: id42\ndata: bar\n\n'
|
|
37
41
|
'data: {"foo": "bar"}\n\n'
|
|
38
42
|
'data: [42, "foo", "bar"]\n\n'
|
|
39
43
|
'data: foo\n\n'
|
|
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
|