Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a329d98a8 | ||
|
|
93411c6a9f | ||
|
|
5550b20cdd | ||
|
|
d8d2667053 | ||
|
|
3943a69374 | ||
|
|
a2f6985d01 | ||
|
|
4238aa4cd4 | ||
|
|
744548f8dc | ||
|
|
d46d2950c8 | ||
|
|
2e4911d108 | ||
|
|
3eb57d0fcf | ||
|
|
42406cef42 | ||
|
|
e09e9830f4 | ||
|
|
304ca2ef68 | ||
|
|
d99df2c401 | ||
|
|
3554bc91cb | ||
|
|
51f910087a | ||
|
|
e0f0565551 | ||
|
|
2a6e76c685 | ||
|
|
42c88b6b20 | ||
|
|
c07a539435 | ||
|
|
e92310fa55 | ||
|
|
9b9b7aa76d | ||
|
|
696f2e3e18 | ||
|
|
87c47ccefc | ||
|
|
a0dd7c8ab6 | ||
|
|
a80841f464 | ||
|
|
f81de6d958 | ||
|
|
efec9f14be | ||
|
|
239cf4ff37 | ||
|
|
87cd098f66 | ||
|
|
bb75e15b2d | ||
|
|
b7ad02eaf1 | ||
|
|
79e11262d1 | ||
|
|
a1b061656f | ||
|
|
67798f7dbf | ||
|
|
ea6766cea9 | ||
|
|
6a31f89673 | ||
|
|
eaf2ef62d1 | ||
|
|
a350e8fd1e | ||
|
|
daf1001ec5 | ||
|
|
e684ee32d9 | ||
|
|
573e303a98 | ||
|
|
3592f53999 | ||
|
|
ea3722ca5c | ||
|
|
358fe6d2cc | ||
|
|
cb39898829 | ||
|
|
db908fe7c3 |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**IMPORTANT**: If you have a question, or you are not sure if you have found a bug in this package, then you are in the wrong place. Hit back in your web browser, and then open a GitHub Discussion instead. Likewise, if you are unable to provide the information requested below, open a discussion to troubleshoot your issue.
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is. If you are getting errors, please include the complete error message, including the stack trace.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: GitHub Discussions
|
||||||
|
url: https://github.com/miguelgrinberg/microdot/discussions
|
||||||
|
about: Ask questions here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
27
.github/workflows/tests.yml
vendored
27
.github/workflows/tests.yml
vendored
@@ -11,8 +11,8 @@ jobs:
|
|||||||
name: lint
|
name: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- run: python -m pip install --upgrade pip wheel
|
- run: python -m pip install --upgrade pip wheel
|
||||||
- run: pip install tox tox-gh-actions
|
- run: pip install tox tox-gh-actions
|
||||||
- run: tox -eflake8
|
- run: tox -eflake8
|
||||||
@@ -25,8 +25,8 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
- run: python -m pip install --upgrade pip wheel
|
- run: python -m pip install --upgrade pip wheel
|
||||||
@@ -36,8 +36,8 @@ jobs:
|
|||||||
name: tests-micropython
|
name: tests-micropython
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- run: python -m pip install --upgrade pip wheel
|
- run: python -m pip install --upgrade pip wheel
|
||||||
- run: pip install tox tox-gh-actions
|
- run: pip install tox tox-gh-actions
|
||||||
- run: tox -eupy
|
- run: tox -eupy
|
||||||
@@ -45,18 +45,21 @@ jobs:
|
|||||||
name: coverage
|
name: coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- run: python -m pip install --upgrade pip wheel
|
- run: python -m pip install --upgrade pip wheel
|
||||||
- run: pip install tox tox-gh-actions codecov
|
- run: pip install tox tox-gh-actions
|
||||||
- run: tox
|
- run: tox
|
||||||
- run: codecov
|
- uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
files: ./coverage.xml
|
||||||
|
fail_ci_if_error: true
|
||||||
benchmark:
|
benchmark:
|
||||||
name: benchmark
|
name: benchmark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v3
|
||||||
- run: python -m pip install --upgrade pip wheel
|
- run: python -m pip install --upgrade pip wheel
|
||||||
- run: pip install tox tox-gh-actions
|
- run: pip install tox tox-gh-actions
|
||||||
- run: tox -ebenchmark
|
- run: tox -ebenchmark
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -103,3 +103,8 @@ venv.bak/
|
|||||||
|
|
||||||
# mypy
|
# mypy
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
|
||||||
|
# other
|
||||||
|
*.der
|
||||||
|
*.pem
|
||||||
|
*_txt.py
|
||||||
|
|||||||
16
.readthedocs.yaml
Normal file
16
.readthedocs.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
build:
|
||||||
|
os: ubuntu-22.04
|
||||||
|
tools:
|
||||||
|
python: "3.11"
|
||||||
|
|
||||||
|
sphinx:
|
||||||
|
configuration: docs/conf.py
|
||||||
|
|
||||||
|
python:
|
||||||
|
install:
|
||||||
|
- method: pip
|
||||||
|
path: .
|
||||||
|
extra_requirements:
|
||||||
|
- docs
|
||||||
41
CHANGES.md
41
CHANGES.md
@@ -1,5 +1,46 @@
|
|||||||
# Microdot change log
|
# Microdot change log
|
||||||
|
|
||||||
|
**Release 1.3.4** - 2023-11-08
|
||||||
|
|
||||||
|
- Handle change in `wait_closed()` behavior in Python 3.12 [#177](https://github.com/miguelgrinberg/microdot/issues/177) ([commit](https://github.com/miguelgrinberg/microdot/commit/5550b20cdd347d59e2aa68f6ebf9e9abffaff9fc))
|
||||||
|
- Added missing request argument in some documentation examples [#163](https://github.com/miguelgrinberg/microdot/issues/163) ([commit](https://github.com/miguelgrinberg/microdot/commit/744548f8dc33a72512b34c4001ee9c6c1edd22ee))
|
||||||
|
- Fix minor documentation typos [#161](https://github.com/miguelgrinberg/microdot/issues/161) ([commit](https://github.com/miguelgrinberg/microdot/commit/2e4911d10826cbb3914de4a45e495c3be36543fa)) (thanks **Andy Piper**!)
|
||||||
|
|
||||||
|
**Release 1.3.3** - 2023-07-16
|
||||||
|
|
||||||
|
- Handle query string arguments without value [#149](https://github.com/miguelgrinberg/microdot/issues/149) ([commit](https://github.com/miguelgrinberg/microdot/commit/3554bc91cb1523efa5b66fe3ef173f8e86e8c2a0))
|
||||||
|
- Support empty responses with ASGI adapter ([commit](https://github.com/miguelgrinberg/microdot/commit/e09e9830f43af41d38775547637558494151a385))
|
||||||
|
- Added CORS extension to Python package ([commit](https://github.com/miguelgrinberg/microdot/commit/304ca2ef6881fe718126b3e308211e760109d519))
|
||||||
|
- Document access to WSGI and ASGI attributes [#153](https://github.com/miguelgrinberg/microdot/issues/153) ([commit](https://github.com/miguelgrinberg/microdot/commit/d99df2c4010ab70c60b86ab334d656903e04eb26))
|
||||||
|
- Upgrade micropython tests to use v1.20 ([commit](https://github.com/miguelgrinberg/microdot/commit/e0f0565551966ee0238a5a1819c78a13639ad704))
|
||||||
|
|
||||||
|
**Release 1.3.2** - 2023-06-13
|
||||||
|
|
||||||
|
- In ASGI, return headers as strings and not binary [#144](https://github.com/miguelgrinberg/microdot/issues/144) ([commit](https://github.com/miguelgrinberg/microdot/commit/e92310fa55bbffcdcbb33f560e27c3579d7ac451))
|
||||||
|
- Incorrect import in `static_async.py` example ([commit](https://github.com/miguelgrinberg/microdot/commit/c07a53943508e64baea160748e67efc92e75b036))
|
||||||
|
|
||||||
|
**Release 1.3.1** - 2023-05-21
|
||||||
|
|
||||||
|
- Support negative numbers for int path components [#137](https://github.com/miguelgrinberg/microdot/issues/137) ([commit](https://github.com/miguelgrinberg/microdot/commit/a0dd7c8ab6d681932324e56ed101aba861a105a0))
|
||||||
|
- Use a more conservative default for socket timeout [#130](https://github.com/miguelgrinberg/microdot/issues/130) ([commit](https://github.com/miguelgrinberg/microdot/commit/239cf4ff37268a7e2467b93be44fe9f91cee8aee))
|
||||||
|
- More robust check for socket timeout error code [#106](https://github.com/miguelgrinberg/microdot/issues/106) ([commit](https://github.com/miguelgrinberg/microdot/commit/efec9f14be7b6f3451e4d1d0fe7e528ce6ca74dc))
|
||||||
|
- WebSocket error when handling PING packet [#129](https://github.com/miguelgrinberg/microdot/issues/129) ([commit](https://github.com/miguelgrinberg/microdot/commit/87cd098f66e24bed6bbad29b1490a129e355bbb3))
|
||||||
|
- Explicitly set UTF-8 encoding for HTML files in examples [#132](https://github.com/miguelgrinberg/microdot/issues/132) ([commit](https://github.com/miguelgrinberg/microdot/commit/f81de6d9582f4905b9c2735d3c639b92d7e77994))
|
||||||
|
|
||||||
|
**Release 1.3.0** - 2023-04-08
|
||||||
|
|
||||||
|
- Cross-Origin Resource Sharing (CORS) extension [#45](https://github.com/miguelgrinberg/microdot/issues/45) ([commit](https://github.com/miguelgrinberg/microdot/commit/67798f7dbffb30018ab4b62a9aaa297f63bc9e64))
|
||||||
|
- Respond to `HEAD` and `OPTIONS` requests ([commit](https://github.com/miguelgrinberg/microdot/commit/6a31f89673518e79fef5659c04e609b7976a5e34))
|
||||||
|
- Tolerate slightly invalid formats in query strings [#126](https://github.com/miguelgrinberg/microdot/issues/126) ([commit](https://github.com/miguelgrinberg/microdot/commit/a1b061656fa19dae583951596b0f1f0603652a56))
|
||||||
|
- Support compressed files in `send_file()` [#93](https://github.com/miguelgrinberg/microdot/issues/93) ([commit](https://github.com/miguelgrinberg/microdot/commit/daf1001ec55ab38e6cdfee4931729a3b7506858b))
|
||||||
|
- Add `max_age` argument to `send_file()` ([commit](https://github.com/miguelgrinberg/microdot/commit/e684ee32d91d3e2ab9569bb5fd342986c010ffeb))
|
||||||
|
- Add `update()` method to `NoCaseDict` class ([commit](https://github.com/miguelgrinberg/microdot/commit/ea6766cea96b756b36ed777f9c1b6a6680db09ba))
|
||||||
|
- Set exit code to 1 for failed MicroPython test runs ([commit](https://github.com/miguelgrinberg/microdot/commit/a350e8fd1e55fac12c9e5b909cfa82d880b177ef))
|
||||||
|
|
||||||
|
**Release 1.2.4** - 2023-03-03
|
||||||
|
|
||||||
|
- One more attempt to correct build issues ([commit](https://github.com/miguelgrinberg/microdot/commit/cb39898829f4edc233ab4e7ba3f7ef3c5c50f196))
|
||||||
|
|
||||||
**Release 1.2.3** - 2023-03-03
|
**Release 1.2.3** - 2023-03-03
|
||||||
|
|
||||||
- Corrected a problem with previous build.
|
- Corrected a problem with previous build.
|
||||||
|
|||||||
5
MANIFEST.in
Normal file
5
MANIFEST.in
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
include README.md LICENSE tox.ini
|
||||||
|
recursive-include docs *
|
||||||
|
recursive-exclude docs/_build *
|
||||||
|
recursive-include tests *
|
||||||
|
exclude **/*.pyc
|
||||||
BIN
bin/micropython
BIN
bin/micropython
Binary file not shown.
@@ -52,6 +52,12 @@ API Reference
|
|||||||
.. automodule:: microdot_session
|
.. automodule:: microdot_session
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
``microdot_cors`` module
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
.. automodule:: microdot_cors
|
||||||
|
:members:
|
||||||
|
|
||||||
``microdot_websocket`` module
|
``microdot_websocket`` module
|
||||||
------------------------------
|
------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ subdirectory. This location can be changed with the
|
|||||||
.. note::
|
.. note::
|
||||||
The Jinja extension is not compatible with MicroPython.
|
The Jinja extension is not compatible with MicroPython.
|
||||||
|
|
||||||
Maintaing Secure User Sessions
|
Maintaining Secure User Sessions
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. list-table::
|
.. list-table::
|
||||||
:align: left
|
:align: left
|
||||||
@@ -208,6 +208,42 @@ Example::
|
|||||||
delete_session(req)
|
delete_session(req)
|
||||||
return redirect('/')
|
return redirect('/')
|
||||||
|
|
||||||
|
Cross-Origin Resource Sharing (CORS)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:align: left
|
||||||
|
|
||||||
|
* - Compatibility
|
||||||
|
- | CPython & MicroPython
|
||||||
|
|
||||||
|
* - Required Microdot source files
|
||||||
|
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_
|
||||||
|
| `microdot_cors.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_cors.py>`_
|
||||||
|
|
||||||
|
* - Required external dependencies
|
||||||
|
- | None
|
||||||
|
|
||||||
|
* - Examples
|
||||||
|
- | `cors.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/cors/cors.py>`_
|
||||||
|
|
||||||
|
The CORS extension provides support for `Cross-Origin Resource Sharing
|
||||||
|
(CORS) <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_. CORS is a
|
||||||
|
mechanism that allows web applications running on different origins to access
|
||||||
|
resources from each other. For example, a web application running on
|
||||||
|
``https://example.com`` can access resources from ``https://api.example.com``.
|
||||||
|
|
||||||
|
To enable CORS support, create an instance of the
|
||||||
|
:class:`CORS <microdot_cors.CORS>` class and configure the desired options.
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from microdot import Microdot
|
||||||
|
from microdot_cors import CORS
|
||||||
|
|
||||||
|
app = Microdot()
|
||||||
|
cors = CORS(app, allowed_origins=['https://example.com'],
|
||||||
|
allow_credentials=True)
|
||||||
|
|
||||||
WebSocket Support
|
WebSocket Support
|
||||||
~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
@@ -450,6 +486,9 @@ web application using the Gunicorn web server::
|
|||||||
|
|
||||||
gunicorn test:app
|
gunicorn test:app
|
||||||
|
|
||||||
|
When using this WSGI adapter, the ``environ`` dictionary provided by the web
|
||||||
|
server is available to request handlers as ``request.environ``.
|
||||||
|
|
||||||
Using an ASGI Web Server
|
Using an ASGI Web Server
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
@@ -493,3 +532,5 @@ web application using the Uvicorn web server::
|
|||||||
|
|
||||||
uvicorn test:app
|
uvicorn test:app
|
||||||
|
|
||||||
|
When using this ASGI adapter, the ``scope`` dictionary provided by the web
|
||||||
|
server is available to request handlers as ``request.asgi_scope``.
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ expected to return an updated response object.
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
The :ref:`request.g <The "g" Object>` object is a special object that allows
|
The :ref:`request.g <The "g" Object>` object is a special object that allows
|
||||||
the before and after request handlers, as well sa the route function to
|
the before and after request handlers, as well as the route function to
|
||||||
share data during the life of the request.
|
share data during the life of the request.
|
||||||
|
|
||||||
Error Handlers
|
Error Handlers
|
||||||
@@ -500,7 +500,7 @@ contents as a file-like object.
|
|||||||
Cookies
|
Cookies
|
||||||
^^^^^^^
|
^^^^^^^
|
||||||
|
|
||||||
Cookies that are sent by the client are made available throught the
|
Cookies that are sent by the client are made available through the
|
||||||
:attr:`cookies <microdot.Request.cookies>` attribute of the request object in
|
:attr:`cookies <microdot.Request.cookies>` attribute of the request object in
|
||||||
dictionary form.
|
dictionary form.
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ always returned to the client in the response body::
|
|||||||
In the above example, Microdot issues a standard 200 status code response, and
|
In the above example, Microdot issues a standard 200 status code response, and
|
||||||
inserts the necessary headers.
|
inserts the necessary headers.
|
||||||
|
|
||||||
The applicaton can provide its own status code as a second value returned from
|
The application can provide its own status code as a second value returned from
|
||||||
the route. The example below returns a 202 status code::
|
the route. The example below returns a 202 status code::
|
||||||
|
|
||||||
@app.get('/')
|
@app.get('/')
|
||||||
@@ -611,7 +611,7 @@ The next example returns an HTML response, instead of a default text response::
|
|||||||
return '<h1>Hello, World!</h1>', 202, {'Content-Type': 'text/html'}
|
return '<h1>Hello, World!</h1>', 202, {'Content-Type': 'text/html'}
|
||||||
|
|
||||||
If the application needs to return custom headers, but does not need to change
|
If the application needs to return custom headers, but does not need to change
|
||||||
the default status code, then it can return two values, omitting the stauts
|
the default status code, then it can return two values, omitting the status
|
||||||
code::
|
code::
|
||||||
|
|
||||||
@app.get('/')
|
@app.get('/')
|
||||||
@@ -662,6 +662,15 @@ object for a file::
|
|||||||
def index(request):
|
def index(request):
|
||||||
return send_file('/static/index.html')
|
return send_file('/static/index.html')
|
||||||
|
|
||||||
|
A suggested caching duration can be returned to the client in the ``max_age``
|
||||||
|
argument::
|
||||||
|
|
||||||
|
from microdot import send_file
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
def image(request):
|
||||||
|
return send_file('/static/image.jpg', max_age=3600) # in seconds
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Unlike other web frameworks, Microdot does not automatically configure a
|
Unlike other web frameworks, Microdot does not automatically configure a
|
||||||
route to serve static files. The following is an example route that can be
|
route to serve static files. The following is an example route that can be
|
||||||
@@ -673,7 +682,7 @@ object for a file::
|
|||||||
if '..' in path:
|
if '..' in path:
|
||||||
# directory traversal is not allowed
|
# directory traversal is not allowed
|
||||||
return 'Not found', 404
|
return 'Not found', 404
|
||||||
return send_file('static/' + path)
|
return send_file('static/' + path, max_age=86400)
|
||||||
|
|
||||||
Streaming Responses
|
Streaming Responses
|
||||||
^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -744,7 +753,7 @@ Another option is to create a response object directly in the route function::
|
|||||||
Standard cookies do not offer sufficient privacy and security controls, so
|
Standard cookies do not offer sufficient privacy and security controls, so
|
||||||
never store sensitive information in them unless you are adding additional
|
never store sensitive information in them unless you are adding additional
|
||||||
protection mechanisms such as encryption or cryptographic signing. The
|
protection mechanisms such as encryption or cryptographic signing. The
|
||||||
:ref:`session <Maintaing Secure User Sessions>` extension implements signed
|
:ref:`session <Maintaining Secure User Sessions>` extension implements signed
|
||||||
cookies that prevent tampering by malicious actors.
|
cookies that prevent tampering by malicious actors.
|
||||||
|
|
||||||
Concurrency
|
Concurrency
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
aiofiles==0.8.0
|
aiofiles==0.8.0
|
||||||
anyio==3.6.1
|
anyio==3.6.1
|
||||||
blinker==1.5
|
blinker==1.5
|
||||||
certifi==2022.12.7
|
certifi==2023.7.22
|
||||||
charset-normalizer==2.1.0
|
charset-normalizer==2.1.0
|
||||||
click==8.1.3
|
click==8.1.3
|
||||||
fastapi==0.79.0
|
fastapi==0.79.0
|
||||||
Flask==2.2.1
|
Flask==2.3.2
|
||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
h11==0.13.0
|
h11==0.13.0
|
||||||
h2==4.1.0
|
h2==4.1.0
|
||||||
@@ -22,12 +22,12 @@ priority==2.0.0
|
|||||||
psutil==5.9.1
|
psutil==5.9.1
|
||||||
pydantic==1.9.1
|
pydantic==1.9.1
|
||||||
quart==0.18.0
|
quart==0.18.0
|
||||||
requests==2.28.1
|
requests==2.31.0
|
||||||
sniffio==1.2.0
|
sniffio==1.2.0
|
||||||
starlette==0.25.0
|
starlette==0.27.0
|
||||||
toml==0.10.2
|
toml==0.10.2
|
||||||
typing_extensions==4.3.0
|
typing_extensions==4.3.0
|
||||||
urllib3==1.26.11
|
urllib3==1.26.18
|
||||||
uvicorn==0.18.2
|
uvicorn==0.18.2
|
||||||
Werkzeug==2.2.3
|
Werkzeug==2.2.3
|
||||||
wsproto==1.1.0
|
wsproto==1.1.0
|
||||||
|
|||||||
1
examples/cors/README.md
Normal file
1
examples/cors/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This directory contains Cross-Origin Resource Sharing (CORS) examples.
|
||||||
14
examples/cors/app.py
Normal file
14
examples/cors/app.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from microdot_cors import CORS
|
||||||
|
|
||||||
|
app = Microdot()
|
||||||
|
CORS(app, allowed_origins=['https://example.org'], allow_credentials=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index(request):
|
||||||
|
return 'Hello World!'
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot GPIO Example</title>
|
<title>Microdot GPIO Example</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
|
||||||
<script>
|
<script>
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ htmldoc = '''<!DOCTYPE html>
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Example Page</title>
|
<title>Microdot Example Page</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ htmldoc = '''<!DOCTYPE html>
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Example Page</title>
|
<title>Microdot Example Page</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ htmldoc = '''<!DOCTYPE html>
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Example Page</title>
|
<title>Microdot Example Page</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ htmldoc = '''<!DOCTYPE html>
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Example Page</title>
|
<title>Microdot Example Page</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ BASE_TEMPLATE = '''<!doctype html>
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot login example</title>
|
<title>Microdot login example</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Microdot login example</h1>
|
<h1>Microdot login example</h1>
|
||||||
@@ -17,7 +18,7 @@ LOGGED_OUT = '''<p>You are not logged in.</p>
|
|||||||
<form method="POST">
|
<form method="POST">
|
||||||
<p>
|
<p>
|
||||||
Username:
|
Username:
|
||||||
<input type="text" name="username" autofocus />
|
<input name="username" autofocus />
|
||||||
</p>
|
</p>
|
||||||
<input type="submit" value="Submit" />
|
<input type="submit" value="Submit" />
|
||||||
</form>'''
|
</form>'''
|
||||||
|
|||||||
20
examples/static/gzstatic.py
Normal file
20
examples/static/gzstatic.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from microdot import Microdot, send_file
|
||||||
|
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index(request):
|
||||||
|
return send_file('gzstatic/index.html', compressed=True,
|
||||||
|
file_extension='.gz')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/static/<path:path>')
|
||||||
|
def static(request, path):
|
||||||
|
if '..' in path:
|
||||||
|
# directory traversal is not allowed
|
||||||
|
return 'Not found', 404
|
||||||
|
return send_file('gzstatic/' + path, compressed=True, file_extension='.gz')
|
||||||
|
|
||||||
|
|
||||||
|
app.run(debug=True)
|
||||||
BIN
examples/static/gzstatic/index.html.gz
Normal file
BIN
examples/static/gzstatic/index.html.gz
Normal file
Binary file not shown.
BIN
examples/static/gzstatic/logo.png.gz
Normal file
BIN
examples/static/gzstatic/logo.png.gz
Normal file
Binary file not shown.
@@ -2,6 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Static File Serving Demo</title>
|
<title>Static File Serving Demo</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Static File Serving Demo</h1>
|
<h1>Static File Serving Demo</h1>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from microdot_asyncio import Microdot
|
from microdot_asyncio import Microdot, send_file
|
||||||
from microdot import send_file
|
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def index(request):
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Video Streaming</title>
|
<title>Microdot Video Streaming</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Microdot Video Streaming</h1>
|
<h1>Microdot Video Streaming</h1>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ def index(request):
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Video Streaming</title>
|
<title>Microdot Video Streaming</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Microdot Video Streaming</h1>
|
<h1>Microdot Video Streaming</h1>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot + Jinja example</title>
|
<title>Microdot + Jinja example</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Microdot + Jinja example</h1>
|
<h1>Microdot + Jinja example</h1>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot + uTemplate example</title>
|
<title>Microdot + uTemplate example</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Microdot + uTemplate example</h1>
|
<h1>Microdot + uTemplate example</h1>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ htmldoc = '''<!DOCTYPE html>
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Example Page</title>
|
<title>Microdot Example Page</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ htmldoc = '''<!DOCTYPE html>
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Example Page</title>
|
<title>Microdot Example Page</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot TLS WebSocket Demo</title>
|
<title>Microdot TLS WebSocket Demo</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Microdot TLS WebSocket Demo</h1>
|
<h1>Microdot TLS WebSocket Demo</h1>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot Upload Example</title>
|
<title>Microdot Upload Example</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Microdot Upload Example</h1>
|
<h1>Microdot Upload Example</h1>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Microdot WebSocket Demo</title>
|
<title>Microdot WebSocket Demo</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Microdot WebSocket Demo</h1>
|
<h1>Microdot WebSocket Demo</h1>
|
||||||
|
|||||||
@@ -1,6 +1,57 @@
|
|||||||
|
[project]
|
||||||
|
name = "microdot"
|
||||||
|
version = "1.3.5.dev0"
|
||||||
|
authors = [
|
||||||
|
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
|
||||||
|
]
|
||||||
|
description = "The impossibly small web framework for MicroPython"
|
||||||
|
classifiers = [
|
||||||
|
"Environment :: Web Environment",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: Implementation :: MicroPython",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.readme]
|
||||||
|
file = "README.md"
|
||||||
|
content-type = "text/markdown"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/miguelgrinberg/microdot"
|
||||||
|
"Bug Tracker" = "https://github.com/miguelgrinberg/microdot/issues"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
docs = [
|
||||||
|
"sphinx",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
zip-safe = false
|
||||||
|
include-package-data = true
|
||||||
|
py-modules = [
|
||||||
|
"microdot",
|
||||||
|
"microdot_asyncio",
|
||||||
|
"microdot_utemplate",
|
||||||
|
"microdot_jinja",
|
||||||
|
"microdot_session",
|
||||||
|
"microdot_cors",
|
||||||
|
"microdot_websocket",
|
||||||
|
"microdot_websocket_alt",
|
||||||
|
"microdot_asyncio_websocket",
|
||||||
|
"microdot_test_client",
|
||||||
|
"microdot_asyncio_test_client",
|
||||||
|
"microdot_wsgi",
|
||||||
|
"microdot_asgi",
|
||||||
|
"microdot_asgi_websocket",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.package-dir]
|
||||||
|
"" = "src"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = [
|
requires = [
|
||||||
"setuptools>=42",
|
"setuptools>=61.2",
|
||||||
"wheel"
|
|
||||||
]
|
]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ sys.path.insert(3, 'libs/micropython')
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
unittest.main('tests')
|
if not unittest.main('tests').wasSuccessful():
|
||||||
|
sys.exit(1)
|
||||||
|
|||||||
43
setup.cfg
43
setup.cfg
@@ -1,43 +0,0 @@
|
|||||||
[metadata]
|
|
||||||
name = microdot
|
|
||||||
version = 1.2.3
|
|
||||||
author = Miguel Grinberg
|
|
||||||
author_email = miguel.grinberg@gmail.com
|
|
||||||
description = The impossibly small web framework for MicroPython
|
|
||||||
long_description = file: README.md
|
|
||||||
long_description_content_type = text/markdown
|
|
||||||
url = https://github.com/miguelgrinberg/microdot
|
|
||||||
project_urls =
|
|
||||||
Bug Tracker = https://github.com/miguelgrinberg/microdot/issues
|
|
||||||
classifiers =
|
|
||||||
Environment :: Web Environment
|
|
||||||
Intended Audience :: Developers
|
|
||||||
Programming Language :: Python :: 3
|
|
||||||
Programming Language :: Python :: Implementation :: MicroPython
|
|
||||||
License :: OSI Approved :: MIT License
|
|
||||||
Operating System :: OS Independent
|
|
||||||
|
|
||||||
[options]
|
|
||||||
zip_safe = False
|
|
||||||
include_package_data = True
|
|
||||||
package_dir =
|
|
||||||
= src
|
|
||||||
packages = find:
|
|
||||||
|
|
||||||
[options.packages.find]
|
|
||||||
where = src
|
|
||||||
|
|
||||||
#py_modules =
|
|
||||||
# microdot
|
|
||||||
# microdot_asyncio
|
|
||||||
# microdot_utemplate
|
|
||||||
# microdot_jinja
|
|
||||||
# microdot_session
|
|
||||||
# microdot_websocket
|
|
||||||
# microdot_websocket_alt
|
|
||||||
# microdot_asyncio_websocket
|
|
||||||
# microdot_test_client
|
|
||||||
# microdot_asyncio_test_client
|
|
||||||
# microdot_wsgi
|
|
||||||
# microdot_asgi
|
|
||||||
# microdot_asgi_websocket
|
|
||||||
125
src/microdot.py
125
src/microdot.py
@@ -146,6 +146,10 @@ class NoCaseDict(dict):
|
|||||||
kl = key.lower()
|
kl = key.lower()
|
||||||
return super().get(self.keymap.get(kl, kl), default)
|
return super().get(self.keymap.get(kl, kl), default)
|
||||||
|
|
||||||
|
def update(self, other_dict):
|
||||||
|
for key, value in other_dict.items():
|
||||||
|
self[key] = value
|
||||||
|
|
||||||
|
|
||||||
def mro(cls): # pragma: no cover
|
def mro(cls): # pragma: no cover
|
||||||
"""Return the method resolution order of a class.
|
"""Return the method resolution order of a class.
|
||||||
@@ -304,8 +308,9 @@ class Request():
|
|||||||
|
|
||||||
#: Specify a suggested read timeout to use when reading the request. Set to
|
#: Specify a suggested read timeout to use when reading the request. Set to
|
||||||
#: 0 to disable the use of a timeout. This timeout should be considered a
|
#: 0 to disable the use of a timeout. This timeout should be considered a
|
||||||
#: suggestion only, as some platforms may not support it.
|
#: suggestion only, as some platforms may not support it. The default is
|
||||||
socket_read_timeout = 0.1
|
#: 1 second.
|
||||||
|
socket_read_timeout = 1
|
||||||
|
|
||||||
class G:
|
class G:
|
||||||
pass
|
pass
|
||||||
@@ -399,13 +404,15 @@ class Request():
|
|||||||
data = MultiDict()
|
data = MultiDict()
|
||||||
if len(urlencoded) > 0:
|
if len(urlencoded) > 0:
|
||||||
if isinstance(urlencoded, str):
|
if isinstance(urlencoded, str):
|
||||||
for k, v in [pair.split('=', 1)
|
for kv in [pair.split('=', 1)
|
||||||
for pair in urlencoded.split('&')]:
|
for pair in urlencoded.split('&') if pair]:
|
||||||
data[urldecode_str(k)] = urldecode_str(v)
|
data[urldecode_str(kv[0])] = urldecode_str(kv[1]) \
|
||||||
|
if len(kv) > 1 else ''
|
||||||
elif isinstance(urlencoded, bytes): # pragma: no branch
|
elif isinstance(urlencoded, bytes): # pragma: no branch
|
||||||
for k, v in [pair.split(b'=', 1)
|
for kv in [pair.split(b'=', 1)
|
||||||
for pair in urlencoded.split(b'&')]:
|
for pair in urlencoded.split(b'&') if pair]:
|
||||||
data[urldecode_bytes(k)] = urldecode_bytes(v)
|
data[urldecode_bytes(kv[0])] = urldecode_bytes(kv[1]) \
|
||||||
|
if len(kv) > 1 else b''
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -525,6 +532,10 @@ class Response():
|
|||||||
#: ``Content-Type`` header.
|
#: ``Content-Type`` header.
|
||||||
default_content_type = 'text/plain'
|
default_content_type = 'text/plain'
|
||||||
|
|
||||||
|
#: The default cache control max age used by :meth:`send_file`. A value
|
||||||
|
#: of ``None`` means that no ``Cache-Control`` header is added.
|
||||||
|
default_send_file_max_age = None
|
||||||
|
|
||||||
#: Special response used to signal that a response does not need to be
|
#: Special response used to signal that a response does not need to be
|
||||||
#: written to the client. Used to exit WebSocket connections cleanly.
|
#: written to the client. Used to exit WebSocket connections cleanly.
|
||||||
already_handled = None
|
already_handled = None
|
||||||
@@ -544,6 +555,7 @@ class Response():
|
|||||||
else:
|
else:
|
||||||
# this applies to bytes, file-like objects or generators
|
# this applies to bytes, file-like objects or generators
|
||||||
self.body = body
|
self.body = body
|
||||||
|
self.is_head = False
|
||||||
|
|
||||||
def set_cookie(self, cookie, value, path=None, domain=None, expires=None,
|
def set_cookie(self, cookie, value, path=None, domain=None, expires=None,
|
||||||
max_age=None, secure=False, http_only=False):
|
max_age=None, secure=False, http_only=False):
|
||||||
@@ -608,19 +620,20 @@ class Response():
|
|||||||
stream.write(b'\r\n')
|
stream.write(b'\r\n')
|
||||||
|
|
||||||
# body
|
# body
|
||||||
can_flush = hasattr(stream, 'flush')
|
if not self.is_head:
|
||||||
try:
|
can_flush = hasattr(stream, 'flush')
|
||||||
for body in self.body_iter():
|
try:
|
||||||
if isinstance(body, str): # pragma: no cover
|
for body in self.body_iter():
|
||||||
body = body.encode()
|
if isinstance(body, str): # pragma: no cover
|
||||||
stream.write(body)
|
body = body.encode()
|
||||||
if can_flush: # pragma: no cover
|
stream.write(body)
|
||||||
stream.flush()
|
if can_flush: # pragma: no cover
|
||||||
except OSError as exc: # pragma: no cover
|
stream.flush()
|
||||||
if exc.errno in MUTED_SOCKET_ERRORS:
|
except OSError as exc: # pragma: no cover
|
||||||
pass
|
if exc.errno in MUTED_SOCKET_ERRORS:
|
||||||
else:
|
pass
|
||||||
raise
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
def body_iter(self):
|
def body_iter(self):
|
||||||
if self.body:
|
if self.body:
|
||||||
@@ -651,7 +664,9 @@ class Response():
|
|||||||
return cls(status_code=status_code, headers={'Location': location})
|
return cls(status_code=status_code, headers={'Location': location})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def send_file(cls, filename, status_code=200, content_type=None):
|
def send_file(cls, filename, status_code=200, content_type=None,
|
||||||
|
stream=None, max_age=None, compressed=False,
|
||||||
|
file_extension=''):
|
||||||
"""Send file contents in a response.
|
"""Send file contents in a response.
|
||||||
|
|
||||||
:param filename: The filename of the file.
|
:param filename: The filename of the file.
|
||||||
@@ -659,7 +674,25 @@ class Response():
|
|||||||
default is 302.
|
default is 302.
|
||||||
:param content_type: The ``Content-Type`` header to use in the
|
:param content_type: The ``Content-Type`` header to use in the
|
||||||
response. If omitted, it is generated
|
response. If omitted, it is generated
|
||||||
automatically from the file extension.
|
automatically from the file extension of the
|
||||||
|
``filename`` parameter.
|
||||||
|
:param stream: A file-like object to read the file contents from. If
|
||||||
|
a stream is given, the ``filename`` parameter is only
|
||||||
|
used when generating the ``Content-Type`` header.
|
||||||
|
:param max_age: The ``Cache-Control`` header's ``max-age`` value in
|
||||||
|
seconds. If omitted, the value of the
|
||||||
|
:attr:`Response.default_send_file_max_age` attribute is
|
||||||
|
used.
|
||||||
|
:param compressed: Whether the file is compressed. If ``True``, the
|
||||||
|
``Content-Encoding`` header is set to ``gzip``. A
|
||||||
|
string with the header value can also be passed.
|
||||||
|
Note that when using this option the file must have
|
||||||
|
been compressed beforehand. This option only sets
|
||||||
|
the header.
|
||||||
|
:param file_extension: A file extension to append to the ``filename``
|
||||||
|
parameter when opening the file, including the
|
||||||
|
dot. The extension given here is not considered
|
||||||
|
when generating the ``Content-Type`` header.
|
||||||
|
|
||||||
Security note: The filename is assumed to be trusted. Never pass
|
Security note: The filename is assumed to be trusted. Never pass
|
||||||
filenames provided by the user without validating and sanitizing them
|
filenames provided by the user without validating and sanitizing them
|
||||||
@@ -671,9 +704,19 @@ class Response():
|
|||||||
content_type = Response.types_map[ext]
|
content_type = Response.types_map[ext]
|
||||||
else:
|
else:
|
||||||
content_type = 'application/octet-stream'
|
content_type = 'application/octet-stream'
|
||||||
f = open(filename, 'rb')
|
headers = {'Content-Type': content_type}
|
||||||
return cls(body=f, status_code=status_code,
|
|
||||||
headers={'Content-Type': content_type})
|
if max_age is None:
|
||||||
|
max_age = cls.default_send_file_max_age
|
||||||
|
if max_age is not None:
|
||||||
|
headers['Cache-Control'] = 'max-age={}'.format(max_age)
|
||||||
|
|
||||||
|
if compressed:
|
||||||
|
headers['Content-Encoding'] = compressed \
|
||||||
|
if isinstance(compressed, str) else 'gzip'
|
||||||
|
|
||||||
|
f = stream or open(filename + file_extension, 'rb')
|
||||||
|
return cls(body=f, status_code=status_code, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
class URLPattern():
|
class URLPattern():
|
||||||
@@ -695,7 +738,7 @@ class URLPattern():
|
|||||||
if type_ == 'string':
|
if type_ == 'string':
|
||||||
pattern = '[^/]+'
|
pattern = '[^/]+'
|
||||||
elif type_ == 'int':
|
elif type_ == 'int':
|
||||||
pattern = '\\d+'
|
pattern = '-?\\d+'
|
||||||
elif type_ == 'path':
|
elif type_ == 'path':
|
||||||
pattern = '.+'
|
pattern = '.+'
|
||||||
elif type_.startswith('re:'):
|
elif type_.startswith('re:'):
|
||||||
@@ -759,6 +802,7 @@ class Microdot():
|
|||||||
self.after_error_request_handlers = []
|
self.after_error_request_handlers = []
|
||||||
self.error_handlers = {}
|
self.error_handlers = {}
|
||||||
self.shutdown_requested = False
|
self.shutdown_requested = False
|
||||||
|
self.options_handler = self.default_options_handler
|
||||||
self.debug = False
|
self.debug = False
|
||||||
self.server = None
|
self.server = None
|
||||||
|
|
||||||
@@ -794,7 +838,8 @@ class Microdot():
|
|||||||
"""
|
"""
|
||||||
def decorated(f):
|
def decorated(f):
|
||||||
self.url_map.append(
|
self.url_map.append(
|
||||||
(methods or ['GET'], URLPattern(url_pattern), f))
|
([m.upper() for m in (methods or ['GET'])],
|
||||||
|
URLPattern(url_pattern), f))
|
||||||
return f
|
return f
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
@@ -1029,7 +1074,7 @@ class Microdot():
|
|||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index(request):
|
||||||
return 'Hello, world!'
|
return 'Hello, world!'
|
||||||
|
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
@@ -1080,17 +1125,32 @@ class Microdot():
|
|||||||
self.shutdown_requested = True
|
self.shutdown_requested = True
|
||||||
|
|
||||||
def find_route(self, req):
|
def find_route(self, req):
|
||||||
|
method = req.method.upper()
|
||||||
|
if method == 'OPTIONS' and self.options_handler:
|
||||||
|
return self.options_handler(req)
|
||||||
|
if method == 'HEAD':
|
||||||
|
method = 'GET'
|
||||||
f = 404
|
f = 404
|
||||||
for route_methods, route_pattern, route_handler in self.url_map:
|
for route_methods, route_pattern, route_handler in self.url_map:
|
||||||
req.url_args = route_pattern.match(req.path)
|
req.url_args = route_pattern.match(req.path)
|
||||||
if req.url_args is not None:
|
if req.url_args is not None:
|
||||||
if req.method in route_methods:
|
if method in route_methods:
|
||||||
f = route_handler
|
f = route_handler
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
f = 405
|
f = 405
|
||||||
return f
|
return f
|
||||||
|
|
||||||
|
def default_options_handler(self, req):
|
||||||
|
allow = []
|
||||||
|
for route_methods, route_pattern, route_handler in self.url_map:
|
||||||
|
if route_pattern.match(req.path) is not None:
|
||||||
|
allow.extend(route_methods)
|
||||||
|
if 'GET' in allow:
|
||||||
|
allow.append('HEAD')
|
||||||
|
allow.append('OPTIONS')
|
||||||
|
return {'Allow': ', '.join(allow)}
|
||||||
|
|
||||||
def handle_request(self, sock, addr):
|
def handle_request(self, sock, addr):
|
||||||
if Request.socket_read_timeout and \
|
if Request.socket_read_timeout and \
|
||||||
hasattr(sock, 'settimeout'): # pragma: no cover
|
hasattr(sock, 'settimeout'): # pragma: no cover
|
||||||
@@ -1106,7 +1166,7 @@ class Microdot():
|
|||||||
req = Request.create(self, stream, addr, sock)
|
req = Request.create(self, stream, addr, sock)
|
||||||
res = self.dispatch_request(req)
|
res = self.dispatch_request(req)
|
||||||
except socket_timeout_error as exc: # pragma: no cover
|
except socket_timeout_error as exc: # pragma: no cover
|
||||||
if exc.errno and exc.errno not in [60, 110]:
|
if exc.errno and exc.errno != errno.ETIMEDOUT:
|
||||||
print_exception(exc) # not a timeout
|
print_exception(exc) # not a timeout
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
print_exception(exc)
|
print_exception(exc)
|
||||||
@@ -1165,6 +1225,8 @@ class Microdot():
|
|||||||
for handler in req.after_request_handlers:
|
for handler in req.after_request_handlers:
|
||||||
res = handler(req, res) or res
|
res = handler(req, res) or res
|
||||||
after_request_handled = True
|
after_request_handled = True
|
||||||
|
elif isinstance(f, dict):
|
||||||
|
res = Response(headers=f)
|
||||||
elif f in self.error_handlers:
|
elif f in self.error_handlers:
|
||||||
res = self.error_handlers[f](req)
|
res = self.error_handlers[f](req)
|
||||||
else:
|
else:
|
||||||
@@ -1208,6 +1270,7 @@ class Microdot():
|
|||||||
if not after_request_handled:
|
if not after_request_handled:
|
||||||
for handler in self.after_error_request_handlers:
|
for handler in self.after_error_request_handlers:
|
||||||
res = handler(req, res) or res
|
res = handler(req, res) or res
|
||||||
|
res.is_head = (req and req.method == 'HEAD')
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ class Microdot(BaseMicrodot):
|
|||||||
headers = NoCaseDict()
|
headers = NoCaseDict()
|
||||||
content_length = 0
|
content_length = 0
|
||||||
for key, value in scope.get('headers', []):
|
for key, value in scope.get('headers', []):
|
||||||
headers[key] = value
|
key = key.decode().title()
|
||||||
if key.lower() == 'content-length':
|
headers[key] = value.decode()
|
||||||
|
if key == 'Content-Length':
|
||||||
content_length = int(value)
|
content_length = int(value)
|
||||||
|
|
||||||
if content_length and content_length <= Request.max_body_length:
|
if content_length and content_length <= Request.max_body_length:
|
||||||
@@ -119,17 +120,18 @@ class Microdot(BaseMicrodot):
|
|||||||
asyncio.ensure_future(cancel_monitor())
|
asyncio.ensure_future(cancel_monitor())
|
||||||
|
|
||||||
body_iter = res.body_iter().__aiter__()
|
body_iter = res.body_iter().__aiter__()
|
||||||
|
res_body = b''
|
||||||
try:
|
try:
|
||||||
body = await body_iter.__anext__()
|
res_body = await body_iter.__anext__()
|
||||||
while not cancelled: # pragma: no branch
|
while not cancelled: # pragma: no branch
|
||||||
next_body = await body_iter.__anext__()
|
next_body = await body_iter.__anext__()
|
||||||
await send({'type': 'http.response.body',
|
await send({'type': 'http.response.body',
|
||||||
'body': body,
|
'body': res_body,
|
||||||
'more_body': True})
|
'more_body': True})
|
||||||
body = next_body
|
res_body = next_body
|
||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
await send({'type': 'http.response.body',
|
await send({'type': 'http.response.body',
|
||||||
'body': body,
|
'body': res_body,
|
||||||
'more_body': False})
|
'more_body': False})
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
async def __call__(self, scope, receive, send):
|
||||||
|
|||||||
@@ -151,10 +151,11 @@ class Response(BaseResponse):
|
|||||||
await stream.awrite(b'\r\n')
|
await stream.awrite(b'\r\n')
|
||||||
|
|
||||||
# body
|
# body
|
||||||
async for body in self.body_iter():
|
if not self.is_head:
|
||||||
if isinstance(body, str): # pragma: no cover
|
async for body in self.body_iter():
|
||||||
body = body.encode()
|
if isinstance(body, str): # pragma: no cover
|
||||||
await stream.awrite(body)
|
body = body.encode()
|
||||||
|
await stream.awrite(body)
|
||||||
except OSError as exc: # pragma: no cover
|
except OSError as exc: # pragma: no cover
|
||||||
if exc.errno in MUTED_SOCKET_ERRORS or \
|
if exc.errno in MUTED_SOCKET_ERRORS or \
|
||||||
exc.args[0] == 'Connection lost':
|
exc.args[0] == 'Connection lost':
|
||||||
@@ -240,7 +241,7 @@ class Microdot(BaseMicrodot):
|
|||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
async def index():
|
async def index(request):
|
||||||
return 'Hello, world!'
|
return 'Hello, world!'
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
@@ -279,6 +280,11 @@ class Microdot(BaseMicrodot):
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
if hasattr(self.server, 'serve_forever'): # pragma: no cover
|
||||||
|
try:
|
||||||
|
await self.server.serve_forever()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
await self.server.wait_closed()
|
await self.server.wait_closed()
|
||||||
break
|
break
|
||||||
except AttributeError: # pragma: no cover
|
except AttributeError: # pragma: no cover
|
||||||
@@ -312,7 +318,7 @@ class Microdot(BaseMicrodot):
|
|||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
async def index():
|
async def index(request):
|
||||||
return 'Hello, world!'
|
return 'Hello, world!'
|
||||||
|
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
@@ -385,6 +391,8 @@ class Microdot(BaseMicrodot):
|
|||||||
res = await self._invoke_handler(
|
res = await self._invoke_handler(
|
||||||
handler, req, res) or res
|
handler, req, res) or res
|
||||||
after_request_handled = True
|
after_request_handled = True
|
||||||
|
elif isinstance(f, dict):
|
||||||
|
res = Response(headers=f)
|
||||||
elif f in self.error_handlers:
|
elif f in self.error_handlers:
|
||||||
res = await self._invoke_handler(
|
res = await self._invoke_handler(
|
||||||
self.error_handlers[f], req)
|
self.error_handlers[f], req)
|
||||||
@@ -431,6 +439,7 @@ class Microdot(BaseMicrodot):
|
|||||||
for handler in self.after_error_request_handlers:
|
for handler in self.after_error_request_handlers:
|
||||||
res = await self._invoke_handler(
|
res = await self._invoke_handler(
|
||||||
handler, req, res) or res
|
handler, req, res) or res
|
||||||
|
res.is_head = (req and req.method == 'HEAD')
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def _invoke_handler(self, f_or_coro, *args, **kwargs):
|
async def _invoke_handler(self, f_or_coro, *args, **kwargs):
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class WebSocket(BaseWebSocket):
|
|||||||
opcode, payload = await self._read_frame()
|
opcode, payload = await self._read_frame()
|
||||||
send_opcode, data = self._process_websocket_frame(opcode, payload)
|
send_opcode, data = self._process_websocket_frame(opcode, payload)
|
||||||
if send_opcode: # pragma: no cover
|
if send_opcode: # pragma: no cover
|
||||||
await self.send(send_opcode, data)
|
await self.send(data, send_opcode)
|
||||||
elif data: # pragma: no branch
|
elif data: # pragma: no branch
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
110
src/microdot_cors.py
Normal file
110
src/microdot_cors.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
class CORS:
|
||||||
|
"""Add CORS headers to HTTP responses.
|
||||||
|
|
||||||
|
:param app: The application to add CORS headers to.
|
||||||
|
:param allowed_origins: A list of origins that are allowed to make
|
||||||
|
cross-site requests. If set to '*', all origins are
|
||||||
|
allowed.
|
||||||
|
:param allow_credentials: If set to True, the
|
||||||
|
``Access-Control-Allow-Credentials`` header will
|
||||||
|
be set to ``true`` to indicate to the browser
|
||||||
|
that it can expose cookies and authentication
|
||||||
|
headers.
|
||||||
|
:param allowed_methods: A list of methods that are allowed to be used when
|
||||||
|
making cross-site requests. If not set, all methods
|
||||||
|
are allowed.
|
||||||
|
:param expose_headers: A list of headers that the browser is allowed to
|
||||||
|
exposed.
|
||||||
|
:param allowed_headers: A list of headers that are allowed to be used when
|
||||||
|
making cross-site requests. If not set, all headers
|
||||||
|
are allowed.
|
||||||
|
:param max_age: The maximum amount of time in seconds that the browser
|
||||||
|
should cache the results of a preflight request.
|
||||||
|
:param handle_cors: If set to False, CORS headers will not be added to
|
||||||
|
responses. This can be useful if you want to add CORS
|
||||||
|
headers manually.
|
||||||
|
"""
|
||||||
|
def __init__(self, app=None, allowed_origins=None, allow_credentials=False,
|
||||||
|
allowed_methods=None, expose_headers=None,
|
||||||
|
allowed_headers=None, max_age=None, handle_cors=True):
|
||||||
|
self.allowed_origins = allowed_origins
|
||||||
|
self.allow_credentials = allow_credentials
|
||||||
|
self.allowed_methods = allowed_methods
|
||||||
|
self.expose_headers = expose_headers
|
||||||
|
self.allowed_headers = None if allowed_headers is None \
|
||||||
|
else [h.lower() for h in allowed_headers]
|
||||||
|
self.max_age = max_age
|
||||||
|
if app is not None:
|
||||||
|
self.initialize(app, handle_cors=handle_cors)
|
||||||
|
|
||||||
|
def initialize(self, app, handle_cors=True):
|
||||||
|
"""Initialize the CORS object for the given application.
|
||||||
|
|
||||||
|
:param app: The application to add CORS headers to.
|
||||||
|
:param handle_cors: If set to False, CORS headers will not be added to
|
||||||
|
responses. This can be useful if you want to add
|
||||||
|
CORS headers manually.
|
||||||
|
"""
|
||||||
|
self.default_options_handler = app.options_handler
|
||||||
|
if handle_cors:
|
||||||
|
app.options_handler = self.options_handler
|
||||||
|
app.after_request(self.after_request)
|
||||||
|
app.after_error_request(self.after_request)
|
||||||
|
|
||||||
|
def options_handler(self, request):
|
||||||
|
headers = self.default_options_handler(request)
|
||||||
|
headers.update(self.get_cors_headers(request))
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def get_cors_headers(self, request):
|
||||||
|
"""Return a dictionary of CORS headers to add to a given request.
|
||||||
|
|
||||||
|
:param request: The request to add CORS headers to.
|
||||||
|
"""
|
||||||
|
cors_headers = {}
|
||||||
|
origin = request.headers.get('Origin')
|
||||||
|
if self.allowed_origins == '*':
|
||||||
|
cors_headers['Access-Control-Allow-Origin'] = origin or '*'
|
||||||
|
if origin:
|
||||||
|
cors_headers['Vary'] = 'Origin'
|
||||||
|
elif origin in (self.allowed_origins or []):
|
||||||
|
cors_headers['Access-Control-Allow-Origin'] = origin
|
||||||
|
cors_headers['Vary'] = 'Origin'
|
||||||
|
if self.allow_credentials and \
|
||||||
|
'Access-Control-Allow-Origin' in cors_headers:
|
||||||
|
cors_headers['Access-Control-Allow-Credentials'] = 'true'
|
||||||
|
if self.expose_headers:
|
||||||
|
cors_headers['Access-Control-Expose-Headers'] = \
|
||||||
|
', '.join(self.expose_headers)
|
||||||
|
|
||||||
|
if request.method == 'OPTIONS':
|
||||||
|
# handle preflight request
|
||||||
|
if self.max_age:
|
||||||
|
cors_headers['Access-Control-Max-Age'] = str(self.max_age)
|
||||||
|
|
||||||
|
method = request.headers.get('Access-Control-Request-Method')
|
||||||
|
if method:
|
||||||
|
method = method.upper()
|
||||||
|
if self.allowed_methods is None or \
|
||||||
|
method in self.allowed_methods:
|
||||||
|
cors_headers['Access-Control-Allow-Methods'] = method
|
||||||
|
|
||||||
|
headers = request.headers.get('Access-Control-Request-Headers')
|
||||||
|
if headers:
|
||||||
|
if self.allowed_headers is None:
|
||||||
|
cors_headers['Access-Control-Allow-Headers'] = headers
|
||||||
|
else:
|
||||||
|
headers = [h.strip() for h in headers.split(',')]
|
||||||
|
headers = [h for h in headers
|
||||||
|
if h.lower() in self.allowed_headers]
|
||||||
|
cors_headers['Access-Control-Allow-Headers'] = \
|
||||||
|
', '.join(headers)
|
||||||
|
|
||||||
|
return cors_headers
|
||||||
|
|
||||||
|
def after_request(self, request, response):
|
||||||
|
saved_vary = response.headers.get('Vary')
|
||||||
|
response.headers.update(self.get_cors_headers(request))
|
||||||
|
if saved_vary and saved_vary != response.headers.get('Vary'):
|
||||||
|
response.headers['Vary'] = (
|
||||||
|
saved_vary + ', ' + response.headers['Vary'])
|
||||||
@@ -58,6 +58,7 @@ class TestResponse:
|
|||||||
test_res._initialize_body(res)
|
test_res._initialize_body(res)
|
||||||
test_res._process_text_body()
|
test_res._process_text_body()
|
||||||
test_res._process_json_body()
|
test_res._process_json_body()
|
||||||
|
test_res.is_head = res.is_head
|
||||||
return test_res
|
return test_res
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class WebSocket:
|
|||||||
opcode, payload = self._read_frame()
|
opcode, payload = self._read_frame()
|
||||||
send_opcode, data = self._process_websocket_frame(opcode, payload)
|
send_opcode, data = self._process_websocket_frame(opcode, payload)
|
||||||
if send_opcode: # pragma: no cover
|
if send_opcode: # pragma: no cover
|
||||||
self.send(send_opcode, data)
|
self.send(data, send_opcode)
|
||||||
elif data: # pragma: no branch
|
elif data: # pragma: no branch
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
BIN
tests/files/test.gz
Normal file
BIN
tests/files/test.gz
Normal file
Binary file not shown.
158
tests/test_cors.py
Normal file
158
tests/test_cors.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import unittest
|
||||||
|
from microdot import Microdot
|
||||||
|
from microdot_test_client import TestClient
|
||||||
|
from microdot_cors import CORS
|
||||||
|
|
||||||
|
|
||||||
|
class TestCORS(unittest.TestCase):
|
||||||
|
def test_origin(self):
|
||||||
|
app = Microdot()
|
||||||
|
cors = CORS(allowed_origins=['https://example.com'],
|
||||||
|
allow_credentials=True)
|
||||||
|
cors.initialize(app)
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.get('/')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertFalse('Access-Control-Allow-Origin' in res.headers)
|
||||||
|
self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
|
||||||
|
self.assertFalse('Vary' in res.headers)
|
||||||
|
|
||||||
|
res = client.get('/', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
|
||||||
|
'https://example.com')
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Credentials'],
|
||||||
|
'true')
|
||||||
|
self.assertEqual(res.headers['Vary'], 'Origin')
|
||||||
|
|
||||||
|
cors.allow_credentials = False
|
||||||
|
|
||||||
|
res = client.get('/foo', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual(res.status_code, 404)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
|
||||||
|
'https://example.com')
|
||||||
|
self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
|
||||||
|
self.assertEqual(res.headers['Vary'], 'Origin')
|
||||||
|
|
||||||
|
res = client.get('/', headers={'Origin': 'https://bad.com'})
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertFalse('Access-Control-Allow-Origin' in res.headers)
|
||||||
|
self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
|
||||||
|
self.assertFalse('Vary' in res.headers)
|
||||||
|
|
||||||
|
def test_all_origins(self):
|
||||||
|
app = Microdot()
|
||||||
|
CORS(app, allowed_origins='*', expose_headers=['X-Test', 'X-Test2'])
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
@app.get('/foo')
|
||||||
|
def foo(req):
|
||||||
|
return 'foo', {'Vary': 'X-Foo, X-Bar'}
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.get('/')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Origin'], '*')
|
||||||
|
self.assertFalse('Vary' in res.headers)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Expose-Headers'],
|
||||||
|
'X-Test, X-Test2')
|
||||||
|
|
||||||
|
res = client.get('/', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
|
||||||
|
'https://example.com')
|
||||||
|
self.assertEqual(res.headers['Vary'], 'Origin')
|
||||||
|
self.assertEqual(res.headers['Access-Control-Expose-Headers'],
|
||||||
|
'X-Test, X-Test2')
|
||||||
|
|
||||||
|
res = client.get('/bad', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual(res.status_code, 404)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
|
||||||
|
'https://example.com')
|
||||||
|
self.assertEqual(res.headers['Vary'], 'Origin')
|
||||||
|
self.assertEqual(res.headers['Access-Control-Expose-Headers'],
|
||||||
|
'X-Test, X-Test2')
|
||||||
|
|
||||||
|
res = client.get('/foo', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Vary'], 'X-Foo, X-Bar, Origin')
|
||||||
|
|
||||||
|
def test_cors_preflight(self):
|
||||||
|
app = Microdot()
|
||||||
|
CORS(app, allowed_origins='*')
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.request('OPTIONS', '/', headers={
|
||||||
|
'Origin': 'https://example.com',
|
||||||
|
'Access-Control-Request-Method': 'POST',
|
||||||
|
'Access-Control-Request-Headers': 'X-Test, X-Test2'})
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
|
||||||
|
'https://example.com')
|
||||||
|
self.assertFalse('Access-Control-Max-Age' in res.headers)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Methods'], 'POST')
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Headers'],
|
||||||
|
'X-Test, X-Test2')
|
||||||
|
|
||||||
|
res = client.request('OPTIONS', '/', headers={
|
||||||
|
'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
|
||||||
|
'https://example.com')
|
||||||
|
self.assertFalse('Access-Control-Max-Age' in res.headers)
|
||||||
|
self.assertFalse('Access-Control-Allow-Methods' in res.headers)
|
||||||
|
self.assertFalse('Access-Control-Allow-Headers' in res.headers)
|
||||||
|
|
||||||
|
def test_cors_preflight_with_options(self):
|
||||||
|
app = Microdot()
|
||||||
|
CORS(app, allowed_origins='*', max_age=3600, allowed_methods=['POST'],
|
||||||
|
allowed_headers=['X-Test'])
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.request('OPTIONS', '/', headers={
|
||||||
|
'Origin': 'https://example.com',
|
||||||
|
'Access-Control-Request-Method': 'POST',
|
||||||
|
'Access-Control-Request-Headers': 'X-Test, X-Test2'})
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Origin'],
|
||||||
|
'https://example.com')
|
||||||
|
self.assertEqual(res.headers['Access-Control-Max-Age'], '3600')
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Methods'], 'POST')
|
||||||
|
self.assertEqual(res.headers['Access-Control-Allow-Headers'], 'X-Test')
|
||||||
|
|
||||||
|
res = client.request('OPTIONS', '/', headers={
|
||||||
|
'Origin': 'https://example.com',
|
||||||
|
'Access-Control-Request-Method': 'GET'})
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertFalse('Access-Control-Allow-Methods' in res.headers)
|
||||||
|
self.assertFalse('Access-Control-Allow-Headers' in res.headers)
|
||||||
|
|
||||||
|
def test_cors_disabled(self):
|
||||||
|
app = Microdot()
|
||||||
|
CORS(app, allowed_origins='*', handle_cors=False)
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.get('/')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertFalse('Access-Control-Allow-Origin' in res.headers)
|
||||||
|
self.assertFalse('Vary' in res.headers)
|
||||||
@@ -63,6 +63,52 @@ class TestMicrodot(unittest.TestCase):
|
|||||||
self.assertEqual(res.headers['Content-Length'], '3')
|
self.assertEqual(res.headers['Content-Length'], '3')
|
||||||
self.assertEqual(res.text, 'bar')
|
self.assertEqual(res.text, 'bar')
|
||||||
|
|
||||||
|
def test_head_request(self):
|
||||||
|
self._mock()
|
||||||
|
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
@app.route('/foo')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
mock_socket.clear_requests()
|
||||||
|
fd = mock_socket.add_request('HEAD', '/foo')
|
||||||
|
self._add_shutdown(app)
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n'))
|
||||||
|
self.assertIn(b'Content-Length: 3\r\n', fd.response)
|
||||||
|
self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n',
|
||||||
|
fd.response)
|
||||||
|
self.assertTrue(fd.response.endswith(b'\r\n\r\n'))
|
||||||
|
|
||||||
|
self._unmock()
|
||||||
|
|
||||||
|
def test_options_request(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET', 'DELETE'])
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
@app.post('/')
|
||||||
|
def index_post(req):
|
||||||
|
return 'bar'
|
||||||
|
|
||||||
|
@app.route('/foo', methods=['POST', 'PUT'])
|
||||||
|
def foo(req):
|
||||||
|
return 'baz'
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.request('OPTIONS', '/')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Allow'],
|
||||||
|
'GET, DELETE, POST, HEAD, OPTIONS')
|
||||||
|
res = client.request('OPTIONS', '/foo')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Allow'], 'POST, PUT, OPTIONS')
|
||||||
|
|
||||||
def test_empty_request(self):
|
def test_empty_request(self):
|
||||||
self._mock()
|
self._mock()
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ class TestMicrodotASGI(unittest.TestCase):
|
|||||||
'type': 'http',
|
'type': 'http',
|
||||||
'path': '/foo/bar',
|
'path': '/foo/bar',
|
||||||
'query_string': b'baz=1',
|
'query_string': b'baz=1',
|
||||||
'headers': [('Authorization', 'Bearer 123'),
|
'headers': [(b'Authorization', b'Bearer 123'),
|
||||||
('Cookie', 'session=xyz'),
|
(b'Cookie', b'session=xyz'),
|
||||||
('Content-Length', 4)],
|
(b'Content-Length', b'4')],
|
||||||
'client': ['1.2.3.4', 1234],
|
'client': ['1.2.3.4', 1234],
|
||||||
'method': 'POST',
|
'method': 'POST',
|
||||||
'http_version': '1.1',
|
'http_version': '1.1',
|
||||||
@@ -114,9 +114,9 @@ class TestMicrodotASGI(unittest.TestCase):
|
|||||||
scope = {
|
scope = {
|
||||||
'type': 'http',
|
'type': 'http',
|
||||||
'path': '/foo/bar',
|
'path': '/foo/bar',
|
||||||
'headers': [('Authorization', 'Bearer 123'),
|
'headers': [(b'Authorization', b'Bearer 123'),
|
||||||
('Cookie', 'session=xyz'),
|
(b'Cookie', b'session=xyz'),
|
||||||
('Content-Length', 4)],
|
(b'Content-Length', b'4')],
|
||||||
'client': ['1.2.3.4', 1234],
|
'client': ['1.2.3.4', 1234],
|
||||||
'method': 'POST',
|
'method': 'POST',
|
||||||
'http_version': '1.1',
|
'http_version': '1.1',
|
||||||
|
|||||||
@@ -101,6 +101,48 @@ class TestMicrodotAsync(unittest.TestCase):
|
|||||||
self.assertEqual(res.body, b'bar-async')
|
self.assertEqual(res.body, b'bar-async')
|
||||||
self.assertEqual(res.json, None)
|
self.assertEqual(res.json, None)
|
||||||
|
|
||||||
|
def test_head_request(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
@app.route('/foo')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
mock_socket.clear_requests()
|
||||||
|
fd = mock_socket.add_request('HEAD', '/foo')
|
||||||
|
self._add_shutdown(app)
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n'))
|
||||||
|
self.assertIn(b'Content-Length: 3\r\n', fd.response)
|
||||||
|
self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n',
|
||||||
|
fd.response)
|
||||||
|
self.assertTrue(fd.response.endswith(b'\r\n\r\n'))
|
||||||
|
|
||||||
|
def test_options_request(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET', 'DELETE'])
|
||||||
|
async def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
@app.post('/')
|
||||||
|
async def index_post(req):
|
||||||
|
return 'bar'
|
||||||
|
|
||||||
|
@app.route('/foo', methods=['POST', 'PUT'])
|
||||||
|
async def foo(req):
|
||||||
|
return 'baz'
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
res = self._run(client.request('OPTIONS', '/'))
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Allow'],
|
||||||
|
'GET, DELETE, POST, HEAD, OPTIONS')
|
||||||
|
res = self._run(client.request('OPTIONS', '/foo'))
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Allow'], 'POST, PUT, OPTIONS')
|
||||||
|
|
||||||
def test_empty_request(self):
|
def test_empty_request(self):
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,11 @@ class TestMultiDict(unittest.TestCase):
|
|||||||
del d['oNE']
|
del d['oNE']
|
||||||
self.assertEqual(list(d.items()), [('two', 5)])
|
self.assertEqual(list(d.items()), [('two', 5)])
|
||||||
self.assertEqual(list(d.values()), [5])
|
self.assertEqual(list(d.values()), [5])
|
||||||
|
|
||||||
|
d.update({'oNe': 1, 'two': 2, 'three': 3})
|
||||||
|
self.assertEqual(d['one'], 1)
|
||||||
|
self.assertEqual(d['ONE'], 1)
|
||||||
|
self.assertEqual(d['two'], 2)
|
||||||
|
self.assertEqual(d['TWO'], 2)
|
||||||
|
self.assertEqual(d['three'], 3)
|
||||||
|
self.assertEqual(d['THREE'], 3)
|
||||||
|
|||||||
@@ -39,9 +39,17 @@ class TestRequest(unittest.TestCase):
|
|||||||
self.assertEqual(req.body, b'aaa')
|
self.assertEqual(req.body, b'aaa')
|
||||||
|
|
||||||
def test_args(self):
|
def test_args(self):
|
||||||
fd = get_request_fd('GET', '/?foo=bar&abc=def&x=%2f%%')
|
fd = get_request_fd('GET', '/?foo=bar&abc=def&foo&x=%2f%%')
|
||||||
req = Request.create('app', fd, 'addr')
|
req = Request.create('app', fd, 'addr')
|
||||||
self.assertEqual(req.query_string, 'foo=bar&abc=def&x=%2f%%')
|
self.assertEqual(req.query_string, 'foo=bar&abc=def&foo&x=%2f%%')
|
||||||
|
md = MultiDict({'foo': 'bar', 'abc': 'def', 'x': '/%%'})
|
||||||
|
md['foo'] = ''
|
||||||
|
self.assertEqual(req.args, md)
|
||||||
|
|
||||||
|
def test_badly_formatted_args(self):
|
||||||
|
fd = get_request_fd('GET', '/?&foo=bar&abc=def&&&x=%2f%%')
|
||||||
|
req = Request.create('app', fd, 'addr')
|
||||||
|
self.assertEqual(req.query_string, '&foo=bar&abc=def&&&x=%2f%%')
|
||||||
self.assertEqual(req.args, MultiDict(
|
self.assertEqual(req.args, MultiDict(
|
||||||
{'foo': 'bar', 'abc': 'def', 'x': '/%%'}))
|
{'foo': 'bar', 'abc': 'def', 'x': '/%%'}))
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ class TestRequestAsync(unittest.TestCase):
|
|||||||
self.assertEqual(req.body, b'aaa')
|
self.assertEqual(req.body, b'aaa')
|
||||||
|
|
||||||
def test_args(self):
|
def test_args(self):
|
||||||
fd = get_async_request_fd('GET', '/?foo=bar&abc=def&x=%2f%%')
|
fd = get_async_request_fd('GET', '/?foo=bar&abc=def&foo&x=%2f%%')
|
||||||
req = _run(Request.create('app', fd, 'writer', 'addr'))
|
req = _run(Request.create('app', fd, 'writer', 'addr'))
|
||||||
self.assertEqual(req.query_string, 'foo=bar&abc=def&x=%2f%%')
|
self.assertEqual(req.query_string, 'foo=bar&abc=def&foo&x=%2f%%')
|
||||||
self.assertEqual(req.args, MultiDict(
|
md = MultiDict({'foo': 'bar', 'abc': 'def', 'x': '/%%'})
|
||||||
{'foo': 'bar', 'abc': 'def', 'x': '/%%'}))
|
md['foo'] = ''
|
||||||
|
self.assertEqual(req.args, md)
|
||||||
|
|
||||||
def test_json(self):
|
def test_json(self):
|
||||||
fd = get_async_request_fd('GET', '/foo', headers={
|
fd = get_async_request_fd('GET', '/foo', headers={
|
||||||
|
|||||||
@@ -235,6 +235,39 @@ class TestResponse(unittest.TestCase):
|
|||||||
b'HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\nfoo\n')
|
b'HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\nfoo\n')
|
||||||
Response.send_file_buffer_size = original_buffer_size
|
Response.send_file_buffer_size = original_buffer_size
|
||||||
|
|
||||||
|
def test_send_file_max_age(self):
|
||||||
|
res = Response.send_file('tests/files/test.txt', max_age=123)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Cache-Control'], 'max-age=123')
|
||||||
|
|
||||||
|
Response.default_send_file_max_age = 456
|
||||||
|
res = Response.send_file('tests/files/test.txt')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Cache-Control'], 'max-age=456')
|
||||||
|
res = Response.send_file('tests/files/test.txt', max_age=123)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Cache-Control'], 'max-age=123')
|
||||||
|
|
||||||
|
Response.default_send_file_max_age = None
|
||||||
|
|
||||||
|
def test_send_file_compressed(self):
|
||||||
|
res = Response.send_file('tests/files/test.txt', compressed=True)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Content-Type'], 'text/plain')
|
||||||
|
self.assertEqual(res.headers['Content-Encoding'], 'gzip')
|
||||||
|
|
||||||
|
res = Response.send_file('tests/files/test.txt', compressed='foo')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Content-Type'], 'text/plain')
|
||||||
|
self.assertEqual(res.headers['Content-Encoding'], 'foo')
|
||||||
|
|
||||||
|
res = Response.send_file('tests/files/test', compressed=True,
|
||||||
|
file_extension='.gz')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers['Content-Type'],
|
||||||
|
'application/octet-stream')
|
||||||
|
self.assertEqual(res.headers['Content-Encoding'], 'gzip')
|
||||||
|
|
||||||
def test_default_content_type(self):
|
def test_default_content_type(self):
|
||||||
original_content_type = Response.default_content_type
|
original_content_type = Response.default_content_type
|
||||||
res = Response('foo')
|
res = Response('foo')
|
||||||
|
|||||||
@@ -56,10 +56,12 @@ class TestURLPattern(unittest.TestCase):
|
|||||||
|
|
||||||
p = URLPattern('/users/<int:id>/<int:id2>/')
|
p = URLPattern('/users/<int:id>/<int:id2>/')
|
||||||
self.assertEqual(p.match('/users/123/456/'), {'id': 123, 'id2': 456})
|
self.assertEqual(p.match('/users/123/456/'), {'id': 123, 'id2': 456})
|
||||||
|
self.assertEqual(p.match('/users/123/-456/'), {'id': 123, 'id2': -456})
|
||||||
self.assertIsNone(p.match('/users/'))
|
self.assertIsNone(p.match('/users/'))
|
||||||
self.assertIsNone(p.match('/users/123/456'))
|
self.assertIsNone(p.match('/users/123/-456'))
|
||||||
self.assertIsNone(p.match('/users/123/abc/'))
|
self.assertIsNone(p.match('/users/123/abc/'))
|
||||||
self.assertIsNone(p.match('/users/123/456/abc'))
|
self.assertIsNone(p.match('/users/123/-456/abc'))
|
||||||
|
self.assertIsNone(p.match('/users/--123/456/'))
|
||||||
|
|
||||||
def test_path_argument(self):
|
def test_path_argument(self):
|
||||||
p = URLPattern('/users/<path:path>')
|
p = URLPattern('/users/<path:path>')
|
||||||
|
|||||||
2
tox.ini
2
tox.ini
@@ -15,7 +15,7 @@ python =
|
|||||||
[testenv]
|
[testenv]
|
||||||
commands=
|
commands=
|
||||||
pip install -e .
|
pip install -e .
|
||||||
pytest -p no:logging --cov=src --cov-config=.coveragerc --cov-branch --cov-report=term-missing
|
pytest -p no:logging --cov=src --cov-config=.coveragerc --cov-branch --cov-report=term-missing --cov-report=xml
|
||||||
deps=
|
deps=
|
||||||
pytest
|
pytest
|
||||||
pytest-cov
|
pytest-cov
|
||||||
|
|||||||
Reference in New Issue
Block a user