50 Commits
v1.2.2 ... v1

Author SHA1 Message Date
Miguel Grinberg
7a329d98a8 Version 1.3.5.dev0 2023-11-08 00:15:07 +00:00
Miguel Grinberg
93411c6a9f Release 1.3.4 2023-11-08 00:14:21 +00:00
Miguel Grinberg
5550b20cdd Handle change in wait_closed() behavior in python 3.12 (Fixes #177) 2023-11-08 00:11:14 +00:00
dependabot[bot]
d8d2667053 Bump urllib3 from 1.26.17 to 1.26.18 in /examples/benchmark (#173) #nolog
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.17 to 1.26.18.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.17...1.26.18)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-18 09:38:45 +01:00
Miguel Grinberg
3943a69374 Migrate Python package metadata to pyproject.toml 2023-10-15 12:51:27 +01:00
dependabot[bot]
a2f6985d01 Bump urllib3 from 1.26.11 to 1.26.17 in /examples/benchmark (#172) #nolog
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.11 to 1.26.17.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.11...1.26.17)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-03 12:10:54 +01:00
dependabot[bot]
4238aa4cd4 Bump flask from 2.2.1 to 2.3.2 in /examples/benchmark (#131) #nolog
Bumps [flask](https://github.com/pallets/flask) from 2.2.1 to 2.3.2.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/2.2.1...2.3.2)

---
updated-dependencies:
- dependency-name: flask
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-14 10:20:02 +01:00
Miguel Grinberg
744548f8dc Added missing request argument in some documentation examples (Fixes #163) 2023-09-01 10:33:58 +01:00
dependabot[bot]
d46d2950c8 Bump certifi from 2022.12.7 to 2023.7.22 in /examples/benchmark (#158) #nolog
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.12.7 to 2023.7.22.
- [Commits](https://github.com/certifi/python-certifi/compare/2022.12.07...2023.07.22)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-20 11:41:24 +01:00
Andy Piper
2e4911d108 Docs: fix minor typos (#161) 2023-08-03 10:40:55 +01:00
Miguel Grinberg
3eb57d0fcf Version 1.3.4.dev0 2023-07-16 11:42:54 +01:00
Miguel Grinberg
42406cef42 Release 1.3.3 2023-07-16 11:41:02 +01:00
Miguel Grinberg
e09e9830f4 Support empty responses with ASGI adapter 2023-07-16 11:36:48 +01:00
Miguel Grinberg
304ca2ef68 Added CORS extension to Python package 2023-06-29 00:36:06 +01:00
Miguel Grinberg
d99df2c401 Document access to WSGI and ASGI attributes (Fixes #153) 2023-06-24 10:34:55 +01:00
Miguel Grinberg
3554bc91cb Handle query string arguments without value (Fixes #149) 2023-06-21 20:20:53 +01:00
Miguel Grinberg
51f910087a Add readthedocs config file 2023-06-20 12:34:59 +01:00
Miguel Grinberg
e0f0565551 Upgrade micropython tests to use v1.20 2023-06-16 16:53:03 +01:00
Miguel Grinberg
2a6e76c685 Version 1.3.3.dev0 2023-06-13 14:45:20 +01:00
Miguel Grinberg
42c88b6b20 Release 1.3.2 2023-06-13 14:45:10 +01:00
Miguel Grinberg
c07a539435 Incorrect import in static_async.py 2023-06-08 00:33:58 +01:00
Miguel Grinberg
e92310fa55 In ASGI, return headers as strings and not binary (Fixes #144) 2023-06-07 23:50:44 +01:00
dependabot[bot]
9b9b7aa76d Bump requests from 2.28.1 to 2.31.0 in /examples/benchmark (#138) #nolog
Bumps [requests](https://github.com/psf/requests) from 2.28.1 to 2.31.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.28.1...v2.31.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-23 09:40:38 +01:00
Miguel Grinberg
696f2e3e18 Version 1.3.2.dev0 2023-05-21 23:37:55 +01:00
Miguel Grinberg
87c47ccefc Release 1.3.1 2023-05-21 23:37:40 +01:00
Miguel Grinberg
a0dd7c8ab6 Support negative numbers for int path components (Fixes #137) 2023-05-21 23:22:03 +01:00
dependabot[bot]
a80841f464 Bump starlette from 0.25.0 to 0.27.0 in /examples/benchmark (#136) #nolog
Bumps [starlette](https://github.com/encode/starlette) from 0.25.0 to 0.27.0.
- [Release notes](https://github.com/encode/starlette/releases)
- [Changelog](https://github.com/encode/starlette/blob/master/docs/release-notes.md)
- [Commits](https://github.com/encode/starlette/compare/0.25.0...0.27.0)

---
updated-dependencies:
- dependency-name: starlette
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-17 11:19:39 +01:00
Miguel Grinberg
f81de6d958 Explicitly set UTF-8 encoding for HTML in examples (Fixes #132) 2023-05-12 09:17:54 +01:00
Miguel Grinberg
efec9f14be More robust check for socket timeout error code (Fixes #106) 2023-04-24 18:24:16 +01:00
Miguel Grinberg
239cf4ff37 Use a more conservative default for socket timeout (Fixes #130) 2023-04-24 18:19:41 +01:00
Miguel Grinberg
87cd098f66 WebSocket error when handling PING packet (Fixes #129) 2023-04-14 15:29:26 +01:00
Miguel Grinberg
bb75e15b2d Upgrade GitHub actions #nolog 2023-04-14 12:56:33 +01:00
Miguel Grinberg
b7ad02eaf1 Version 1.3.1.dev0 2023-04-08 17:23:33 +01:00
Miguel Grinberg
79e11262d1 Release 1.3.0 2023-04-08 17:21:30 +01:00
Miguel Grinberg
a1b061656f Tolerate slightly invalid formats in query strings (Fixes #126) 2023-04-08 17:15:54 +01:00
Miguel Grinberg
67798f7dbf Cross-Origin Resource Sharing (CORS) support (Fixes #45) 2023-03-23 00:03:21 +00:00
Miguel Grinberg
ea6766cea9 Add update() method to NoCaseDict class 2023-03-22 20:22:29 +00:00
Miguel Grinberg
6a31f89673 Respond to HEAD and OPTIONS requests 2023-03-22 12:25:21 +00:00
Miguel Grinberg
eaf2ef62d1 Documentation typo #nolog 2023-03-21 00:32:22 +00:00
Miguel Grinberg
a350e8fd1e Set exit code to 1 for failed MicroPython test runs 2023-03-21 00:28:23 +00:00
Miguel Grinberg
daf1001ec5 Support compressed files in send_file() (Fixes #93) 2023-03-21 00:24:57 +00:00
Miguel Grinberg
e684ee32d9 Add max_age argument to send_file() 2023-03-20 12:11:01 +00:00
Miguel Grinberg
573e303a98 Issue templates #nolog 2023-03-03 14:49:10 +00:00
Miguel Grinberg
3592f53999 Update gitignore #nolog 2023-03-03 11:06:50 +00:00
Miguel Grinberg
ea3722ca5c Version 1.2.5.dev0 2023-03-03 08:46:27 +00:00
Miguel Grinberg
358fe6d2cc Release 1.2.4 2023-03-03 08:40:22 +00:00
Miguel Grinberg
cb39898829 One more attempt to correct build issues 2023-03-03 08:39:21 +00:00
Miguel Grinberg
db908fe7c3 Version 1.2.4.dev0 2023-03-03 08:20:53 +00:00
Miguel Grinberg
cb856e1bc7 Release 1.2.3 2023-03-03 08:19:59 +00:00
Miguel Grinberg
110d7de6a9 Version 1.2.3.dev0 2023-03-03 07:19:57 +00:00
57 changed files with 857 additions and 137 deletions

26
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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
View 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.

View 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.

View File

@@ -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
View File

@@ -103,3 +103,8 @@ venv.bak/
# mypy # mypy
.mypy_cache/ .mypy_cache/
# other
*.der
*.pem
*_txt.py

16
.readthedocs.yaml Normal file
View 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

View File

@@ -1,5 +1,50 @@
# 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
- Corrected a problem with previous build.
**Release 1.2.2** - 2023-03-03 **Release 1.2.2** - 2023-03-03
- Add a socket read timeout to abort incomplete requests [#99](https://github.com/miguelgrinberg/microdot/issues/99) ([commit](https://github.com/miguelgrinberg/microdot/commit/d0d358f94a63f8565d6406feff0c6e7418cc7f81)) - Add a socket read timeout to abort incomplete requests [#99](https://github.com/miguelgrinberg/microdot/issues/99) ([commit](https://github.com/miguelgrinberg/microdot/commit/d0d358f94a63f8565d6406feff0c6e7418cc7f81))

5
MANIFEST.in Normal file
View File

@@ -0,0 +1,5 @@
include README.md LICENSE tox.ini
recursive-include docs *
recursive-exclude docs/_build *
recursive-include tests *
exclude **/*.pyc

Binary file not shown.

View File

@@ -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
------------------------------ ------------------------------

View File

@@ -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``.

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1 @@
This directory contains Cross-Origin Resource Sharing (CORS) examples.

14
examples/cors/app.py Normal file
View 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()

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>'''

View 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)

Binary file not shown.

Binary file not shown.

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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)

View File

@@ -1,43 +0,0 @@
[metadata]
name = microdot
version = 1.2.2
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

View File

@@ -1,3 +0,0 @@
import setuptools
setuptools.setup()

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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
View 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'])

View File

@@ -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

View File

@@ -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

Binary file not shown.

158
tests/test_cors.py Normal file
View 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)

View File

@@ -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()

View File

@@ -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',

View File

@@ -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()

View File

@@ -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)

View File

@@ -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': '/%%'}))

View File

@@ -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={

View File

@@ -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')

View File

@@ -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>')

View File

@@ -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