70 Commits
v1.2.1 ... 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
Miguel Grinberg
46b120bc87 Release 1.2.2 2023-03-03 07:19:47 +00:00
Miguel Grinberg
ddb3b8f442 Return headers as lowercase byte sequences as required by ASGI 2023-03-03 07:15:09 +00:00
Miguel Grinberg
9398c96075 Add CPU timing to benchmark 2023-02-28 23:30:58 +00:00
Miguel Grinberg
4d432a7d6c More robust timeout handling (Fixes #106) 2023-02-28 18:31:54 +00:00
Miguel Grinberg
d0d358f94a Add a socket read timeout to abort incomplete requests (Fixes #99) 2023-02-22 00:42:20 +00:00
Miguel Grinberg
680cd9c023 Async example of static file serving 2023-02-21 15:18:04 +00:00
dependabot[bot]
ec72d54203 Bump werkzeug from 2.2.1 to 2.2.3 in /examples/benchmark (#102) #nolog
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/2.2.1...2.2.3)

---
updated-dependencies:
- dependency-name: werkzeug
  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-02-16 10:02:41 +00:00
Eric Welch
c00b24c943 Fixing broken links to examples in documentation (#101) 2023-02-15 16:07:17 +00:00
dependabot[bot]
878a911afc Bump starlette from 0.19.1 to 0.25.0 in /examples/benchmark (#100) 2023-02-14 23:27:49 +00:00
Miguel Grinberg
ecd84ecb7b Update unittest library for MicroPython 2023-02-07 00:03:53 +00:00
Miguel Grinberg
fcaeee6905 Add @after_error_handler decorator (Fixes #97) 2023-02-06 23:53:11 +00:00
Miguel Grinberg
427a4d49de Correct path in benchmark test #nolog 2023-01-13 14:58:16 +00:00
Miguel Grinberg
f56c826149 Update tox.ini #nolog 2023-01-13 11:39:23 +00:00
Miguel Grinberg
2aa90d4245 Add scrollbar to documentation's left sidebar 2023-01-13 10:27:51 +00:00
William Wheeler
8139498023 Documentation typo (#90) 2022-12-20 07:24:49 +00:00
Miguel Grinberg
3d6815119c Upgrade uasyncio release used in tests 2022-12-09 17:12:57 +00:00
Miguel Grinberg
818f98d9a4 New build of micropython 2022-12-09 12:15:35 +00:00
Miguel Grinberg
dd15d90239 Remove 3.6, add 3.11 2022-12-09 11:20:07 +00:00
dependabot[bot]
d42388d6fe Bump certifi from 2022.6.15 to 2022.12.7 in /examples/benchmark (#88) #nolog
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.6.15 to 2022.12.7.
- [Release notes](https://github.com/certifi/python-certifi/releases)
- [Commits](https://github.com/certifi/python-certifi/compare/2022.06.15...2022.12.07)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-09 11:07:23 +00:00
Miguel Grinberg
1abe8edc56 Version 1.2.2.dev0 2022-12-06 12:38:26 +00:00
69 changed files with 1582 additions and 351 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
@@ -21,12 +21,12 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-latest, windows-latest]
python: ['3.6', '3.7', '3.8', '3.9', '3.10'] python: ['3.7', '3.8', '3.9', '3.10', '3.11']
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,66 @@
# 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
- 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))
- More robust timeout handling [#106](https://github.com/miguelgrinberg/microdot/issues/106) ([commit](https://github.com/miguelgrinberg/microdot/commit/4d432a7d6cd88b874a8b825fb62891ed22881f74))
- Add @after_error_handler decorator [#97](https://github.com/miguelgrinberg/microdot/issues/97) ([commit](https://github.com/miguelgrinberg/microdot/commit/fcaeee69052b5681706f65b022e667baeee30d4d))
- Return headers as lowercase byte sequences as required by ASGI ([commit](https://github.com/miguelgrinberg/microdot/commit/ddb3b8f442d3683df04554104edaf8acd9c68148))
- Async example of static file serving ([commit](https://github.com/miguelgrinberg/microdot/commit/680cd9c023352f0ff03d67f1041ea174b7b7385b))
- Fixing broken links to examples in documentation [#101](https://github.com/miguelgrinberg/microdot/issues/101) ([commit](https://github.com/miguelgrinberg/microdot/commit/c00b24c9436e1b8f3d4c9bb6f2adfca988902e91)) (thanks **Eric Welch**!)
- Add scrollbar to documentation's left sidebar ([commit](https://github.com/miguelgrinberg/microdot/commit/2aa90d42451dc64c84efcc4f40a1b6c8d1ef1e8d))
- Documentation typo [#90](https://github.com/miguelgrinberg/microdot/issues/90) ([commit](https://github.com/miguelgrinberg/microdot/commit/81394980234f24aac834faf8e2e8225231e9014b)) (thanks **William Wheeler**!)
- Add CPU timing to benchmark ([commit](https://github.com/miguelgrinberg/microdot/commit/9398c960752f87bc32d7c4349cbf594e5d678e99))
- Upgrade uasyncio release used in tests ([commit](https://github.com/miguelgrinberg/microdot/commit/3d6815119ca1ec989f704f626530f938c857a8e5))
- Update unittest library for MicroPython ([commit](https://github.com/miguelgrinberg/microdot/commit/ecd84ecb7bd3c29d5af96739442b908badeab804))
- New build of micropython for unit tests ([commit](https://github.com/miguelgrinberg/microdot/commit/818f98d9a4e531e01c0f913813425ab2b40c289d))
- Remove 3.6, add 3.11 to builds ([commit](https://github.com/miguelgrinberg/microdot/commit/dd15d90239b73b5fd413515c9cd4ac23f6d42f67))
**Release 1.2.1** - 2022-12-06 **Release 1.2.1** - 2022-12-06
- Error handling invokes parent exceptions [#74](https://github.com/miguelgrinberg/microdot/issues/74) ([commit](https://github.com/miguelgrinberg/microdot/commit/24d74fb8483b04e8abe6e303e06f0a310f32700b)) (thanks **Diego Pomares**!) - Error handling invokes parent exceptions [#74](https://github.com/miguelgrinberg/microdot/issues/74) ([commit](https://github.com/miguelgrinberg/microdot/commit/24d74fb8483b04e8abe6e303e06f0a310f32700b)) (thanks **Diego Pomares**!)

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

@@ -1,3 +1,8 @@
.py.class, .py.function, .py.method, .py.property { .py.class, .py.function, .py.method, .py.property {
margin-top: 20px; margin-top: 20px;
} }
div.sphinxsidebar {
max-height: 100%;
overflow-y: auto;
}

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

@@ -23,7 +23,7 @@ Asynchronous Support with Asyncio
| MicroPython: `uasyncio <https://github.com/micropython/micropython/tree/master/extmod/uasyncio>`_ | MicroPython: `uasyncio <https://github.com/micropython/micropython/tree/master/extmod/uasyncio>`_
* - Examples * - Examples
- | `hello_async.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello_async.py>`_ - | `hello_async.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_async.py>`_
Microdot can be extended to use an asynchronous programming model based on the Microdot can be extended to use an asynchronous programming model based on the
``asyncio`` package. When the :class:`Microdot <microdot_asyncio.Microdot>` ``asyncio`` package. When the :class:`Microdot <microdot_asyncio.Microdot>`
@@ -68,8 +68,8 @@ Using the uTemplate Engine
- | `utemplate <https://github.com/pfalcon/utemplate/tree/master/utemplate>`_ - | `utemplate <https://github.com/pfalcon/utemplate/tree/master/utemplate>`_
* - Examples * - Examples
- | `hello_utemplate.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello_utemplate.py>`_ - | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello.py>`_
| `hello_utemplate_async.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello_utemplate_async.py>`_ | `hello_utemplate_async.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_utemplate_async.py>`_
The :func:`render_template <microdot_utemplate.render_template>` function is The :func:`render_template <microdot_utemplate.render_template>` function is
used to render HTML templates with the uTemplate engine. The first argument is used to render HTML templates with the uTemplate engine. The first argument is
@@ -110,7 +110,7 @@ Using the Jinja Engine
- | `Jinja2 <https://jinja.palletsprojects.com/>`_ - | `Jinja2 <https://jinja.palletsprojects.com/>`_
* - Examples * - Examples
- | `hello_jinja.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello_jinja.py>`_ - | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello.py>`_
The :func:`render_template <microdot_jinja.render_template>` function is used The :func:`render_template <microdot_jinja.render_template>` function is used
to render HTML templates with the Jinja engine. The first argument is the to render HTML templates with the Jinja engine. The first argument is the
@@ -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
@@ -156,7 +156,7 @@ Maintaing Secure User Sessions
`hmac <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/hmac/hmac.py>`_ `hmac <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/hmac/hmac.py>`_
* - Examples * - Examples
- | `login.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/login.py>`_ - | `login.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/sessions/login.py>`_
The session extension provides a secure way for the application to maintain The session extension provides a secure way for the application to maintain
user sessions. The session is stored as a signed cookie in the client's user sessions. The session is stored as a signed cookie in the client's
@@ -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
~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~
@@ -297,7 +333,7 @@ HTTPS Support
* - Examples * - Examples
- | `hello_tls.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/tls/hello_tls.py>`_ - | `hello_tls.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/tls/hello_tls.py>`_
| `hello_asyncio_tls.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/tls/hello_asyncio_tls.py>`_ | `hello_async_tls.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/tls/hello_async_tls.py>`_
The ``run()`` function accepts an optional ``ssl`` argument, through which an The ``run()`` function accepts an optional ``ssl`` argument, through which an
initialized ``SSLContext`` object can be passed. MicroPython does not currently initialized ``SSLContext`` object can be passed. MicroPython does not currently
@@ -423,7 +459,7 @@ Using a WSGI Web Server
- | A WSGI web server, such as `Gunicorn <https://gunicorn.org/>`_. - | A WSGI web server, such as `Gunicorn <https://gunicorn.org/>`_.
* - Examples * - Examples
- | `hello_wsgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello_wsgi.py>`_ - | `hello_wsgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_wsgi.py>`_
The ``microdot_wsgi`` module provides an extended ``Microdot`` class that The ``microdot_wsgi`` module provides an extended ``Microdot`` class that
@@ -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
^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
@@ -468,7 +507,7 @@ Using an ASGI Web Server
- | An ASGI web server, such as `Uvicorn <https://uvicorn.org/>`_. - | An ASGI web server, such as `Uvicorn <https://uvicorn.org/>`_.
* - Examples * - Examples
- | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello_asgi.py>`_ - | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_asgi.py>`_
The ``microdot_asgi`` module provides an extended ``Microdot`` class that The ``microdot_asgi`` module provides an extended ``Microdot`` class that
implements the ASGI protocol and can be used with a compliant ASGI server such implements the ASGI protocol and can be used with a compliant ASGI server such
@@ -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

@@ -283,7 +283,7 @@ handled::
def start_timer(request): def start_timer(request):
request.g.start_time = time.time() request.g.start_time = time.time()
@ap.after_request @app.after_request
def end_timer(request, response): def end_timer(request, response):
duration = time.time() - request.g.start_time duration = time.time() - request.g.start_time
print(f'Request took {duration:0.2f} seconds') print(f'Request took {duration:0.2f} seconds')
@@ -293,9 +293,15 @@ The function can return a modified response object to replace the original. If
the function does not return a value, then the original response object is the function does not return a value, then the original response object is
used. used.
The after request handlers are only invoked for successful requests. The
:func:`after_error_request() <microdot.Microdot.after_error_request>`
decorator can be used to register a function that is called after an error
occurs. The function receives the request and the error response and is
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
@@ -494,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.
@@ -589,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('/')
@@ -605,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('/')
@@ -656,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
@@ -667,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
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
@@ -738,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.6.15 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.19.1 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.1 Werkzeug==2.2.3
wsproto==1.1.0 wsproto==1.1.0

View File

@@ -1,6 +1,7 @@
import os import os
import subprocess import subprocess
import time import time
from timeit import timeit
import requests import requests
import psutil import psutil
import humanize import humanize
@@ -76,19 +77,23 @@ apps = [
for app, env, name in apps: for app, env, name in apps:
p = subprocess.Popen( p = subprocess.Popen(
app.split() if isinstance(app, str) else app, app.split() if isinstance(app, str) else app,
env={'PATH': os.environ['PATH'], **env}, env={'PATH': os.environ['PATH'] + ':../../bin', **env},
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL stderr=subprocess.DEVNULL
) )
time.sleep(1) time.sleep(1)
tm = 0
if not name.startswith('baseline'): if not name.startswith('baseline'):
r = requests.get('http://localhost:5000') def req():
r.raise_for_status() r = requests.get('http://localhost:5000')
r.raise_for_status()
tm = timeit(req, number=1000)
proc = psutil.Process(p.pid) proc = psutil.Process(p.pid)
mem = proc.memory_info().rss mem = proc.memory_info().rss
for child in proc.children(recursive=True): for child in proc.children(recursive=True):
mem += child.memory_info().rss mem += child.memory_info().rss
bar = '*' * (mem // (1024 * 1024)) bar = '*' * (mem // (1024 * 1024))
print(f'{name:<28}{humanize.naturalsize(mem):>10} {bar}') print(f'{name:<28}{tm:10.2f}s {humanize.naturalsize(mem):>10} {bar}')
p.terminate() p.terminate()
time.sleep(1) time.sleep(1)

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

@@ -0,0 +1,18 @@
from microdot_asyncio import Microdot, send_file
app = Microdot()
@app.route('/')
async def index(request):
return send_file('static/index.html')
@app.route('/static/<path:path>')
async def static(request, path):
if '..' in path:
# directory traversal is not allowed
return 'Not found', 404
return send_file('static/' + path)
app.run(debug=True)

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

@@ -41,7 +41,7 @@ class SingletonGenerator:
def __next__(self): def __next__(self):
if self.state is not None: if self.state is not None:
_task_queue.push_sorted(cur_task, self.state) _task_queue.push(cur_task, self.state)
self.state = None self.state = None
return None return None
else: else:
@@ -115,11 +115,11 @@ class IOQueue:
# print('poll', s, sm, ev) # print('poll', s, sm, ev)
if ev & ~select.POLLOUT and sm[0] is not None: if ev & ~select.POLLOUT and sm[0] is not None:
# POLLIN or error # POLLIN or error
_task_queue.push_head(sm[0]) _task_queue.push(sm[0])
sm[0] = None sm[0] = None
if ev & ~select.POLLIN and sm[1] is not None: if ev & ~select.POLLIN and sm[1] is not None:
# POLLOUT or error # POLLOUT or error
_task_queue.push_head(sm[1]) _task_queue.push(sm[1])
sm[1] = None sm[1] = None
if sm[0] is None and sm[1] is None: if sm[0] is None and sm[1] is None:
self._dequeue(s) self._dequeue(s)
@@ -142,7 +142,7 @@ def create_task(coro):
if not hasattr(coro, "send"): if not hasattr(coro, "send"):
raise TypeError("coroutine expected") raise TypeError("coroutine expected")
t = Task(coro, globals()) t = Task(coro, globals())
_task_queue.push_head(t) _task_queue.push(t)
return t return t
@@ -167,7 +167,7 @@ def run_until_complete(main_task=None):
_io_queue.wait_io_event(dt) _io_queue.wait_io_event(dt)
# Get next task to run and continue it # Get next task to run and continue it
t = _task_queue.pop_head() t = _task_queue.pop()
cur_task = t cur_task = t
try: try:
# Continue running the coroutine, it's responsible for rescheduling itself # Continue running the coroutine, it's responsible for rescheduling itself
@@ -175,6 +175,10 @@ def run_until_complete(main_task=None):
if not exc: if not exc:
t.coro.send(None) t.coro.send(None)
else: else:
# If the task is finished and on the run queue and gets here, then it
# had an exception and was not await'ed on. Throwing into it now will
# raise StopIteration and the code below will catch this and run the
# call_exception_handler function.
t.data = None t.data = None
t.coro.throw(exc) t.coro.throw(exc)
except excs_all as er: except excs_all as er:
@@ -185,22 +189,37 @@ def run_until_complete(main_task=None):
if isinstance(er, StopIteration): if isinstance(er, StopIteration):
return er.value return er.value
raise er raise er
# Schedule any other tasks waiting on the completion of this task if t.state:
waiting = False # Task was running but is now finished.
if hasattr(t, "waiting"): waiting = False
while t.waiting.peek(): if t.state is True:
_task_queue.push_head(t.waiting.pop_head()) # "None" indicates that the task is complete and not await'ed on (yet).
t.state = None
elif callable(t.state):
# The task has a callback registered to be called on completion.
t.state(t, er)
t.state = False
waiting = True waiting = True
t.waiting = None # Free waiting queue head else:
if not waiting and not isinstance(er, excs_stop): # Schedule any other tasks waiting on the completion of this task.
# An exception ended this detached task, so queue it for later while t.state.peek():
# execution to handle the uncaught exception if no other task retrieves _task_queue.push(t.state.pop())
# the exception in the meantime (this is handled by Task.throw). waiting = True
_task_queue.push_head(t) # "False" indicates that the task is complete and has been await'ed on.
# Indicate task is done by setting coro to the task object itself t.state = False
t.coro = t if not waiting and not isinstance(er, excs_stop):
# Save return value of coro to pass up to caller # An exception ended this detached task, so queue it for later
t.data = er # execution to handle the uncaught exception if no other task retrieves
# the exception in the meantime (this is handled by Task.throw).
_task_queue.push(t)
# Save return value of coro to pass up to caller.
t.data = er
elif t.state is None:
# Task is already finished and nothing await'ed on the task,
# so call the exception handler.
_exc_context["exception"] = exc
_exc_context["future"] = t
Loop.call_exception_handler(_exc_context)
# Create a new task from a coroutine and run it until it finishes # Create a new task from a coroutine and run it until it finishes
@@ -237,7 +256,7 @@ class Loop:
def stop(): def stop():
global _stop_task global _stop_task
if _stop_task is not None: if _stop_task is not None:
_task_queue.push_head(_stop_task) _task_queue.push(_stop_task)
# If stop() is called again, do nothing # If stop() is called again, do nothing
_stop_task = None _stop_task = None

View File

@@ -17,7 +17,7 @@ class Event:
# Note: This must not be called from anything except the thread running # Note: This must not be called from anything except the thread running
# the asyncio loop (i.e. neither hard or soft IRQ, or a different thread). # the asyncio loop (i.e. neither hard or soft IRQ, or a different thread).
while self.waiting.peek(): while self.waiting.peek():
core._task_queue.push_head(self.waiting.pop_head()) core._task_queue.push(self.waiting.pop())
self.state = True self.state = True
def clear(self): def clear(self):
@@ -26,7 +26,7 @@ class Event:
async def wait(self): async def wait(self):
if not self.state: if not self.state:
# Event not set, put the calling task on the event's waiting queue # Event not set, put the calling task on the event's waiting queue
self.waiting.push_head(core.cur_task) self.waiting.push(core.cur_task)
# Set calling task's data to the event's queue so it can be removed if needed # Set calling task's data to the event's queue so it can be removed if needed
core.cur_task.data = self.waiting core.cur_task.data = self.waiting
yield yield
@@ -36,27 +36,29 @@ class Event:
# MicroPython-extension: This can be set from outside the asyncio event loop, # MicroPython-extension: This can be set from outside the asyncio event loop,
# such as other threads, IRQs or scheduler context. Implementation is a stream # such as other threads, IRQs or scheduler context. Implementation is a stream
# that asyncio will poll until a flag is set. # that asyncio will poll until a flag is set.
# Note: Unlike Event, this is self-clearing. # Note: Unlike Event, this is self-clearing after a wait().
try: try:
import uio import uio
class ThreadSafeFlag(uio.IOBase): class ThreadSafeFlag(uio.IOBase):
def __init__(self): def __init__(self):
self._flag = 0 self.state = 0
def ioctl(self, req, flags): def ioctl(self, req, flags):
if req == 3: # MP_STREAM_POLL if req == 3: # MP_STREAM_POLL
return self._flag * flags return self.state * flags
return None return None
def set(self): def set(self):
self._flag = 1 self.state = 1
def clear(self):
self.state = 0
async def wait(self): async def wait(self):
if not self._flag: if not self.state:
yield core._io_queue.queue_read(self) yield core._io_queue.queue_read(self)
self._flag = 0 self.state = 0
except ImportError: except ImportError:
pass pass

View File

@@ -1,49 +1,51 @@
# MicroPython uasyncio module # MicroPython uasyncio module
# MIT license; Copyright (c) 2019-2020 Damien P. George # MIT license; Copyright (c) 2019-2022 Damien P. George
from . import core from . import core
def _run(waiter, aw):
try:
result = await aw
status = True
except BaseException as er:
result = None
status = er
if waiter.data is None:
# The waiter is still waiting, cancel it.
if waiter.cancel():
# Waiter was cancelled by us, change its CancelledError to an instance of
# CancelledError that contains the status and result of waiting on aw.
# If the wait_for task subsequently gets cancelled externally then this
# instance will be reset to a CancelledError instance without arguments.
waiter.data = core.CancelledError(status, result)
async def wait_for(aw, timeout, sleep=core.sleep): async def wait_for(aw, timeout, sleep=core.sleep):
aw = core._promote_to_task(aw) aw = core._promote_to_task(aw)
if timeout is None: if timeout is None:
return await aw return await aw
def runner(waiter, aw):
nonlocal status, result
try:
result = await aw
s = True
except BaseException as er:
s = er
if status is None:
# The waiter is still waiting, set status for it and cancel it.
status = s
waiter.cancel()
# Run aw in a separate runner task that manages its exceptions. # Run aw in a separate runner task that manages its exceptions.
status = None runner_task = core.create_task(_run(core.cur_task, aw))
result = None
runner_task = core.create_task(runner(core.cur_task, aw))
try: try:
# Wait for the timeout to elapse. # Wait for the timeout to elapse.
await sleep(timeout) await sleep(timeout)
except core.CancelledError as er: except core.CancelledError as er:
if status is True: status = er.value
# aw completed successfully and cancelled the sleep, so return aw's result. if status is None:
return result
elif status is None:
# This wait_for was cancelled externally, so cancel aw and re-raise. # This wait_for was cancelled externally, so cancel aw and re-raise.
status = True
runner_task.cancel() runner_task.cancel()
raise er raise er
elif status is True:
# aw completed successfully and cancelled the sleep, so return aw's result.
return er.args[1]
else: else:
# aw raised an exception, propagate it out to the caller. # aw raised an exception, propagate it out to the caller.
raise status raise status
# The sleep finished before aw, so cancel aw and raise TimeoutError. # The sleep finished before aw, so cancel aw and raise TimeoutError.
status = True
runner_task.cancel() runner_task.cancel()
await runner_task await runner_task
raise core.TimeoutError raise core.TimeoutError
@@ -53,22 +55,75 @@ def wait_for_ms(aw, timeout):
return wait_for(aw, timeout, core.sleep_ms) return wait_for(aw, timeout, core.sleep_ms)
class _Remove:
@staticmethod
def remove(t):
pass
async def gather(*aws, return_exceptions=False): async def gather(*aws, return_exceptions=False):
if not aws:
return []
def done(t, er):
# Sub-task "t" has finished, with exception "er".
nonlocal state
if gather_task.data is not _Remove:
# The main gather task has already been scheduled, so do nothing.
# This happens if another sub-task already raised an exception and
# woke the main gather task (via this done function), or if the main
# gather task was cancelled externally.
return
elif not return_exceptions and not isinstance(er, StopIteration):
# A sub-task raised an exception, indicate that to the gather task.
state = er
else:
state -= 1
if state:
# Still some sub-tasks running.
return
# Gather waiting is done, schedule the main gather task.
core._task_queue.push(gather_task)
ts = [core._promote_to_task(aw) for aw in aws] ts = [core._promote_to_task(aw) for aw in aws]
for i in range(len(ts)): for i in range(len(ts)):
try: if ts[i].state is not True:
# TODO handle cancel of gather itself # Task is not running, gather not currently supported for this case.
# if ts[i].coro: raise RuntimeError("can't gather")
# iter(ts[i]).waiting.push_head(cur_task) # Register the callback to call when the task is done.
# try: ts[i].state = done
# yield
# except CancelledError as er: # Set the state for execution of the gather.
# # cancel all waiting tasks gather_task = core.cur_task
# raise er state = len(ts)
ts[i] = await ts[i] cancel_all = False
except Exception as er:
if return_exceptions: # Wait for the a sub-task to need attention.
ts[i] = er gather_task.data = _Remove
else: try:
raise er yield
except core.CancelledError as er:
cancel_all = True
state = er
# Clean up tasks.
for i in range(len(ts)):
if ts[i].state is done:
# Sub-task is still running, deregister the callback and cancel if needed.
ts[i].state = True
if cancel_all:
ts[i].cancel()
elif isinstance(ts[i].data, StopIteration):
# Sub-task ran to completion, get its return value.
ts[i] = ts[i].data.value
else:
# Sub-task had an exception with return_exceptions==True, so get its exception.
ts[i] = ts[i].data
# Either this gather was cancelled, or one of the sub-tasks raised an exception with
# return_exceptions==False, so reraise the exception here.
if state is not 0:
raise state
# Return the list of return values of each sub-task.
return ts return ts

View File

@@ -22,8 +22,8 @@ class Lock:
raise RuntimeError("Lock not acquired") raise RuntimeError("Lock not acquired")
if self.waiting.peek(): if self.waiting.peek():
# Task(s) waiting on lock, schedule next Task # Task(s) waiting on lock, schedule next Task
self.state = self.waiting.pop_head() self.state = self.waiting.pop()
core._task_queue.push_head(self.state) core._task_queue.push(self.state)
else: else:
# No Task waiting so unlock # No Task waiting so unlock
self.state = 0 self.state = 0
@@ -31,7 +31,7 @@ class Lock:
async def acquire(self): async def acquire(self):
if self.state != 0: if self.state != 0:
# Lock unavailable, put the calling Task on the waiting queue # Lock unavailable, put the calling Task on the waiting queue
self.waiting.push_head(core.cur_task) self.waiting.push(core.cur_task)
# Set calling task's data to the lock's queue so it can be removed if needed # Set calling task's data to the lock's queue so it can be removed if needed
core.cur_task.data = self.waiting core.cur_task.data = self.waiting
try: try:

View File

@@ -1,13 +1,15 @@
# This list of frozen files doesn't include task.py because that's provided by the C module. # This list of package files doesn't include task.py because that's provided
freeze( # by the C module.
"..", package(
"uasyncio",
( (
"uasyncio/__init__.py", "__init__.py",
"uasyncio/core.py", "core.py",
"uasyncio/event.py", "event.py",
"uasyncio/funcs.py", "funcs.py",
"uasyncio/lock.py", "lock.py",
"uasyncio/stream.py", "stream.py",
), ),
base_path="..",
opt=3, opt=3,
) )

View File

@@ -26,9 +26,21 @@ class Stream:
# TODO yield? # TODO yield?
self.s.close() self.s.close()
async def read(self, n): async def read(self, n=-1):
r = b""
while True:
yield core._io_queue.queue_read(self.s)
r2 = self.s.read(n)
if r2 is not None:
if n >= 0:
return r2
if not len(r2):
return r
r += r2
async def readinto(self, buf):
yield core._io_queue.queue_read(self.s) yield core._io_queue.queue_read(self.s)
return self.s.read(n) return self.s.readinto(buf)
async def readexactly(self, n): async def readexactly(self, n):
r = b"" r = b""
@@ -52,9 +64,19 @@ class Stream:
return l return l
def write(self, buf): def write(self, buf):
if not self.out_buf:
# Try to write immediately to the underlying stream.
ret = self.s.write(buf)
if ret == len(buf):
return
if ret is not None:
buf = buf[ret:]
self.out_buf += buf self.out_buf += buf
async def drain(self): async def drain(self):
if not self.out_buf:
# Drain must always yield, so a tight loop of write+drain can't block the scheduler.
return await core.sleep_ms(0)
mv = memoryview(self.out_buf) mv = memoryview(self.out_buf)
off = 0 off = 0
while off < len(mv): while off < len(mv):
@@ -75,8 +97,8 @@ async def open_connection(host, port):
from uerrno import EINPROGRESS from uerrno import EINPROGRESS
import usocket as socket import usocket as socket
ai = socket.getaddrinfo(host, port)[0] # TODO this is blocking! ai = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0] # TODO this is blocking!
s = socket.socket() s = socket.socket(ai[0], ai[1], ai[2])
s.setblocking(False) s.setblocking(False)
ss = Stream(s) ss = Stream(s)
try: try:
@@ -103,16 +125,7 @@ class Server:
async def wait_closed(self): async def wait_closed(self):
await self.task await self.task
async def _serve(self, cb, host, port, backlog): async def _serve(self, s, cb):
import usocket as socket
ai = socket.getaddrinfo(host, port)[0] # TODO this is blocking!
s = socket.socket()
s.setblocking(False)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(ai[-1])
s.listen(backlog)
self.task = core.cur_task
# Accept incoming connections # Accept incoming connections
while True: while True:
try: try:
@@ -134,9 +147,20 @@ class Server:
# Helper function to start a TCP stream server, running as a new task # Helper function to start a TCP stream server, running as a new task
# TODO could use an accept-callback on socket read activity instead of creating a task # TODO could use an accept-callback on socket read activity instead of creating a task
async def start_server(cb, host, port, backlog=5): async def start_server(cb, host, port, backlog=5):
s = Server() import usocket as socket
core.create_task(s._serve(cb, host, port, backlog))
return s # Create and bind server socket.
host = socket.getaddrinfo(host, port)[0] # TODO this is blocking!
s = socket.socket()
s.setblocking(False)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(host[-1])
s.listen(backlog)
# Create and return server object and task.
srv = Server()
srv.task = core.create_task(srv._serve(s, cb))
return srv
################################################################################ ################################################################################

View File

@@ -99,19 +99,18 @@ class TaskQueue:
def peek(self): def peek(self):
return self.heap return self.heap
def push_sorted(self, v, key): def push(self, v, key=None):
assert v.ph_child is None
assert v.ph_next is None
v.data = None v.data = None
v.ph_key = key v.ph_key = key if key is not None else core.ticks()
v.ph_child = None
v.ph_next = None
self.heap = ph_meld(v, self.heap) self.heap = ph_meld(v, self.heap)
def push_head(self, v): def pop(self):
self.push_sorted(v, core.ticks())
def pop_head(self):
v = self.heap v = self.heap
self.heap = ph_pairing(self.heap.ph_child) assert v.ph_next is None
self.heap = ph_pairing(v.ph_child)
v.ph_child = None
return v return v
def remove(self, v): def remove(self, v):
@@ -123,6 +122,7 @@ class Task:
def __init__(self, coro, globals=None): def __init__(self, coro, globals=None):
self.coro = coro # Coroutine of this Task self.coro = coro # Coroutine of this Task
self.data = None # General data for queue it is waiting on self.data = None # General data for queue it is waiting on
self.state = True # None, False, True, a callable, or a TaskQueue instance
self.ph_key = 0 # Pairing heap self.ph_key = 0 # Pairing heap
self.ph_child = None # Paring heap self.ph_child = None # Paring heap
self.ph_child_last = None # Paring heap self.ph_child_last = None # Paring heap
@@ -130,30 +130,33 @@ class Task:
self.ph_rightmost_parent = None # Paring heap self.ph_rightmost_parent = None # Paring heap
def __iter__(self): def __iter__(self):
if self.coro is self: if not self.state:
# Signal that the completed-task has been await'ed on. # Task finished, signal that is has been await'ed on.
self.waiting = None self.state = False
elif not hasattr(self, "waiting"): elif self.state is True:
# Lazily allocated head of linked list of Tasks waiting on completion of this task. # Allocated head of linked list of Tasks waiting on completion of this task.
self.waiting = TaskQueue() self.state = TaskQueue()
elif type(self.state) is not TaskQueue:
# Task has state used for another purpose, so can't also wait on it.
raise RuntimeError("can't wait")
return self return self
def __next__(self): def __next__(self):
if self.coro is self: if not self.state:
# Task finished, raise return value to caller so it can continue. # Task finished, raise return value to caller so it can continue.
raise self.data raise self.data
else: else:
# Put calling task on waiting queue. # Put calling task on waiting queue.
self.waiting.push_head(core.cur_task) self.state.push(core.cur_task)
# Set calling task's data to this task that it waits on, to double-link it. # Set calling task's data to this task that it waits on, to double-link it.
core.cur_task.data = self core.cur_task.data = self
def done(self): def done(self):
return self.coro is self return not self.state
def cancel(self): def cancel(self):
# Check if task is already finished. # Check if task is already finished.
if self.coro is self: if not self.state:
return False return False
# Can't cancel self (not supported yet). # Can't cancel self (not supported yet).
if self is core.cur_task: if self is core.cur_task:
@@ -165,20 +168,10 @@ class Task:
if hasattr(self.data, "remove"): if hasattr(self.data, "remove"):
# Not on the main running queue, remove the task from the queue it's on. # Not on the main running queue, remove the task from the queue it's on.
self.data.remove(self) self.data.remove(self)
core._task_queue.push_head(self) core._task_queue.push(self)
elif core.ticks_diff(self.ph_key, core.ticks()) > 0: elif core.ticks_diff(self.ph_key, core.ticks()) > 0:
# On the main running queue but scheduled in the future, so bring it forward to now. # On the main running queue but scheduled in the future, so bring it forward to now.
core._task_queue.remove(self) core._task_queue.remove(self)
core._task_queue.push_head(self) core._task_queue.push(self)
self.data = core.CancelledError self.data = core.CancelledError
return True return True
def throw(self, value):
# This task raised an exception which was uncaught; handle that now.
# Set the data because it was cleared by the main scheduling loop.
self.data = value
if not hasattr(self, "waiting"):
# Nothing await'ed on the task so call the exception handler.
core._exc_context["exception"] = value
core._exc_context["future"] = self
core.Loop.call_exception_handler(core._exc_context)

View File

@@ -1,12 +1,18 @@
import io
import os
import sys import sys
try:
import traceback
except ImportError:
traceback = None
class SkipTest(Exception): class SkipTest(Exception):
pass pass
class AssertRaisesContext: class AssertRaisesContext:
def __init__(self, exc): def __init__(self, exc):
self.expected = exc self.expected = exc
@@ -14,29 +20,98 @@ class AssertRaisesContext:
return self return self
def __exit__(self, exc_type, exc_value, tb): def __exit__(self, exc_type, exc_value, tb):
self.exception = exc_value
if exc_type is None: if exc_type is None:
assert False, "%r not raised" % self.expected assert False, "%r not raised" % self.expected
if issubclass(exc_type, self.expected): if issubclass(exc_type, self.expected):
# store exception for later retrieval
self.exception = exc_value
return True return True
return False return False
class TestCase: # These are used to provide required context to things like subTest
__current_test__ = None
__test_result__ = None
def fail(self, msg=''):
class SubtestContext:
def __init__(self, msg=None, params=None):
self.msg = msg
self.params = params
def __enter__(self):
pass
def __exit__(self, *exc_info):
if exc_info[0] is not None:
# Exception raised
global __test_result__, __current_test__
test_details = __current_test__
if self.msg:
test_details += (f" [{self.msg}]",)
if self.params:
detail = ", ".join(f"{k}={v}" for k, v in self.params.items())
test_details += (f" ({detail})",)
_handle_test_exception(test_details, __test_result__, exc_info, False)
# Suppress the exception as we've captured it above
return True
class NullContext:
def __enter__(self):
pass
def __exit__(self, exc_type, exc_value, traceback):
pass
class TestCase:
def __init__(self):
pass
def addCleanup(self, func, *args, **kwargs):
if not hasattr(self, "_cleanups"):
self._cleanups = []
self._cleanups.append((func, args, kwargs))
def doCleanups(self):
if hasattr(self, "_cleanups"):
while self._cleanups:
func, args, kwargs = self._cleanups.pop()
func(*args, **kwargs)
def subTest(self, msg=None, **params):
return SubtestContext(msg=msg, params=params)
def skipTest(self, reason):
raise SkipTest(reason)
def fail(self, msg=""):
assert False, msg assert False, msg
def assertEqual(self, x, y, msg=''): def assertEqual(self, x, y, msg=""):
if not msg: if not msg:
msg = "%r vs (expected) %r" % (x, y) msg = "%r vs (expected) %r" % (x, y)
assert x == y, msg assert x == y, msg
def assertNotEqual(self, x, y, msg=''): def assertNotEqual(self, x, y, msg=""):
if not msg: if not msg:
msg = "%r not expected to be equal %r" % (x, y) msg = "%r not expected to be equal %r" % (x, y)
assert x != y, msg assert x != y, msg
def assertAlmostEqual(self, x, y, places=None, msg='', delta=None): def assertLessEqual(self, x, y, msg=None):
if msg is None:
msg = "%r is expected to be <= %r" % (x, y)
assert x <= y, msg
def assertGreaterEqual(self, x, y, msg=None):
if msg is None:
msg = "%r is expected to be >= %r" % (x, y)
assert x >= y, msg
def assertAlmostEqual(self, x, y, places=None, msg="", delta=None):
if x == y: if x == y:
return return
if delta is not None and places is not None: if delta is not None and places is not None:
@@ -46,18 +121,18 @@ class TestCase:
if abs(x - y) <= delta: if abs(x - y) <= delta:
return return
if not msg: if not msg:
msg = '%r != %r within %r delta' % (x, y, delta) msg = "%r != %r within %r delta" % (x, y, delta)
else: else:
if places is None: if places is None:
places = 7 places = 7
if round(abs(y-x), places) == 0: if round(abs(y - x), places) == 0:
return return
if not msg: if not msg:
msg = '%r != %r within %r places' % (x, y, places) msg = "%r != %r within %r places" % (x, y, places)
assert False, msg assert False, msg
def assertNotAlmostEqual(self, x, y, places=None, msg='', delta=None): def assertNotAlmostEqual(self, x, y, places=None, msg="", delta=None):
if delta is not None and places is not None: if delta is not None and places is not None:
raise TypeError("specify delta or places not both") raise TypeError("specify delta or places not both")
@@ -65,53 +140,53 @@ class TestCase:
if not (x == y) and abs(x - y) > delta: if not (x == y) and abs(x - y) > delta:
return return
if not msg: if not msg:
msg = '%r == %r within %r delta' % (x, y, delta) msg = "%r == %r within %r delta" % (x, y, delta)
else: else:
if places is None: if places is None:
places = 7 places = 7
if not (x == y) and round(abs(y-x), places) != 0: if not (x == y) and round(abs(y - x), places) != 0:
return return
if not msg: if not msg:
msg = '%r == %r within %r places' % (x, y, places) msg = "%r == %r within %r places" % (x, y, places)
assert False, msg assert False, msg
def assertIs(self, x, y, msg=''): def assertIs(self, x, y, msg=""):
if not msg: if not msg:
msg = "%r is not %r" % (x, y) msg = "%r is not %r" % (x, y)
assert x is y, msg assert x is y, msg
def assertIsNot(self, x, y, msg=''): def assertIsNot(self, x, y, msg=""):
if not msg: if not msg:
msg = "%r is %r" % (x, y) msg = "%r is %r" % (x, y)
assert x is not y, msg assert x is not y, msg
def assertIsNone(self, x, msg=''): def assertIsNone(self, x, msg=""):
if not msg: if not msg:
msg = "%r is not None" % x msg = "%r is not None" % x
assert x is None, msg assert x is None, msg
def assertIsNotNone(self, x, msg=''): def assertIsNotNone(self, x, msg=""):
if not msg: if not msg:
msg = "%r is None" % x msg = "%r is None" % x
assert x is not None, msg assert x is not None, msg
def assertTrue(self, x, msg=''): def assertTrue(self, x, msg=""):
if not msg: if not msg:
msg = "Expected %r to be True" % x msg = "Expected %r to be True" % x
assert x, msg assert x, msg
def assertFalse(self, x, msg=''): def assertFalse(self, x, msg=""):
if not msg: if not msg:
msg = "Expected %r to be False" % x msg = "Expected %r to be False" % x
assert not x, msg assert not x, msg
def assertIn(self, x, y, msg=''): def assertIn(self, x, y, msg=""):
if not msg: if not msg:
msg = "Expected %r to be in %r" % (x, y) msg = "Expected %r to be in %r" % (x, y)
assert x in y, msg assert x in y, msg
def assertIsInstance(self, x, y, msg=''): def assertIsInstance(self, x, y, msg=""):
assert isinstance(x, y), msg assert isinstance(x, y), msg
def assertRaises(self, exc, func=None, *args, **kwargs): def assertRaises(self, exc, func=None, *args, **kwargs):
@@ -120,12 +195,15 @@ class TestCase:
try: try:
func(*args, **kwargs) func(*args, **kwargs)
assert False, "%r not raised" % exc
except Exception as e: except Exception as e:
if isinstance(e, exc): if isinstance(e, exc):
return return
raise raise
assert False, "%r not raised" % exc
def assertWarns(self, warn):
return NullContext()
def skip(msg): def skip(msg):
@@ -133,92 +211,252 @@ def skip(msg):
# We just replace original fun with _inner # We just replace original fun with _inner
def _inner(self): def _inner(self):
raise SkipTest(msg) raise SkipTest(msg)
return _inner return _inner
return _decor return _decor
def skipIf(cond, msg): def skipIf(cond, msg):
if not cond: if not cond:
return lambda x: x return lambda x: x
return skip(msg) return skip(msg)
def skipUnless(cond, msg): def skipUnless(cond, msg):
if cond: if cond:
return lambda x: x return lambda x: x
return skip(msg) return skip(msg)
def expectedFailure(test):
def test_exp_fail(*args, **kwargs):
try:
test(*args, **kwargs)
except:
pass
else:
assert False, "unexpected success"
return test_exp_fail
class TestSuite: class TestSuite:
def __init__(self): def __init__(self, name=""):
self.tests = [] self._tests = []
self.name = name
def addTest(self, cls): def addTest(self, cls):
self.tests.append(cls) self._tests.append(cls)
def run(self, result):
for c in self._tests:
_run_suite(c, result, self.name)
return result
def _load_module(self, mod):
for tn in dir(mod):
c = getattr(mod, tn)
if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase):
self.addTest(c)
elif tn.startswith("test") and callable(c):
self.addTest(c)
class TestRunner: class TestRunner:
def run(self, suite): def run(self, suite: TestSuite):
res = TestResult() res = TestResult()
for c in suite.tests: suite.run(res)
run_class(c, res)
res.printErrors()
print("----------------------------------------------------------------------")
print("Ran %d tests\n" % res.testsRun) print("Ran %d tests\n" % res.testsRun)
if res.failuresNum > 0 or res.errorsNum > 0: if res.failuresNum > 0 or res.errorsNum > 0:
print("FAILED (failures=%d, errors=%d)" % (res.failuresNum, res.errorsNum)) print("FAILED (failures=%d, errors=%d)" % (res.failuresNum, res.errorsNum))
else: else:
msg = "OK" msg = "OK"
if res.skippedNum > 0: if res.skippedNum > 0:
msg += " (%d skipped)" % res.skippedNum msg += " (skipped=%d)" % res.skippedNum
print(msg) print(msg)
return res return res
TextTestRunner = TestRunner
class TestResult: class TestResult:
def __init__(self): def __init__(self):
self.errorsNum = 0 self.errorsNum = 0
self.failuresNum = 0 self.failuresNum = 0
self.skippedNum = 0 self.skippedNum = 0
self.testsRun = 0 self.testsRun = 0
self.errors = []
self.failures = []
self.skipped = []
self._newFailures = 0
def wasSuccessful(self): def wasSuccessful(self):
return self.errorsNum == 0 and self.failuresNum == 0 return self.errorsNum == 0 and self.failuresNum == 0
# TODO: Uncompliant def printErrors(self):
def run_class(c, test_result): print()
o = c() self.printErrorList(self.errors)
self.printErrorList(self.failures)
def printErrorList(self, lst):
sep = "----------------------------------------------------------------------"
for c, e in lst:
detail = " ".join((str(i) for i in c))
print("======================================================================")
print(f"FAIL: {detail}")
print(sep)
print(e)
def __repr__(self):
# Format is compatible with CPython.
return "<unittest.result.TestResult run=%d errors=%d failures=%d>" % (
self.testsRun,
self.errorsNum,
self.failuresNum,
)
def __add__(self, other):
self.errorsNum += other.errorsNum
self.failuresNum += other.failuresNum
self.skippedNum += other.skippedNum
self.testsRun += other.testsRun
self.errors.extend(other.errors)
self.failures.extend(other.failures)
self.skipped.extend(other.skipped)
return self
def _capture_exc(exc, exc_traceback):
buf = io.StringIO()
if hasattr(sys, "print_exception"):
sys.print_exception(exc, buf)
elif traceback is not None:
traceback.print_exception(None, exc, exc_traceback, file=buf)
return buf.getvalue()
def _handle_test_exception(
current_test: tuple, test_result: TestResult, exc_info: tuple, verbose=True
):
exc = exc_info[1]
traceback = exc_info[2]
ex_str = _capture_exc(exc, traceback)
if isinstance(exc, AssertionError):
test_result.failuresNum += 1
test_result.failures.append((current_test, ex_str))
if verbose:
print(" FAIL")
else:
test_result.errorsNum += 1
test_result.errors.append((current_test, ex_str))
if verbose:
print(" ERROR")
test_result._newFailures += 1
def _run_suite(c, test_result: TestResult, suite_name=""):
if isinstance(c, TestSuite):
c.run(test_result)
return
if isinstance(c, type):
o = c()
else:
o = c
set_up_class = getattr(o, "setUpClass", lambda: None)
tear_down_class = getattr(o, "tearDownClass", lambda: None)
set_up = getattr(o, "setUp", lambda: None) set_up = getattr(o, "setUp", lambda: None)
tear_down = getattr(o, "tearDown", lambda: None) tear_down = getattr(o, "tearDown", lambda: None)
for name in dir(o): exceptions = []
if name.startswith("test"): try:
print("%s (%s) ..." % (name, c.__qualname__), end="") suite_name += "." + c.__qualname__
m = getattr(o, name) except AttributeError:
set_up() pass
try:
test_result.testsRun += 1 def run_one(test_function):
m() global __test_result__, __current_test__
print(" ok") print("%s (%s) ..." % (name, suite_name), end="")
except SkipTest as e: set_up()
print(" skipped:", e.args[0]) __test_result__ = test_result
test_result.skippedNum += 1 test_container = f"({suite_name})"
except: __current_test__ = (name, test_container)
try:
test_result._newFailures = 0
test_result.testsRun += 1
test_function()
# No exception occurred, test passed
if test_result._newFailures:
print(" FAIL") print(" FAIL")
test_result.failuresNum += 1 else:
# Uncomment to investigate failure in detail print(" ok")
#raise except SkipTest as e:
continue reason = e.args[0]
finally: print(" skipped:", reason)
tear_down() test_result.skippedNum += 1
test_result.skipped.append((name, c, reason))
except Exception as ex:
_handle_test_exception(
current_test=(name, c), test_result=test_result, exc_info=(type(ex), ex, None)
)
# Uncomment to investigate failure in detail
# raise
finally:
__test_result__ = None
__current_test__ = None
tear_down()
try:
o.doCleanups()
except AttributeError:
pass
set_up_class()
try:
if hasattr(o, "runTest"):
name = str(o)
run_one(o.runTest)
return
for name in dir(o):
if name.startswith("test"):
m = getattr(o, name)
if not callable(m):
continue
run_one(m)
if callable(o):
name = o.__name__
run_one(o)
finally:
tear_down_class()
return exceptions
def main(module="__main__"): # This supports either:
def test_cases(m): #
for tn in dir(m): # >>> import mytest
c = getattr(m, tn) # >>> unitttest.main(mytest)
if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase): #
yield c # >>> unittest.main("mytest")
#
# Or, a script that ends with:
# if __name__ == "__main__":
# unittest.main()
# e.g. run via `mpremote run mytest.py`
def main(module="__main__", testRunner=None):
if testRunner is None:
testRunner = TestRunner()
elif isinstance(testRunner, type):
testRunner = testRunner()
m = __import__(module) if isinstance(module, str):
suite = TestSuite() module = __import__(module)
for c in test_cases(m): suite = TestSuite(module.__name__)
suite.addTest(c) suite._load_module(module)
runner = TestRunner() return testRunner.run(suite)
result = runner.run(suite)
# Terminate with non zero return code in case of failures
sys.exit(result.failuresNum > 0)

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,38 +0,0 @@
[metadata]
name = microdot
version = 1.2.1
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
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

@@ -43,11 +43,13 @@ try:
except ImportError: except ImportError:
import re import re
socket_timeout_error = OSError
try: try:
import usocket as socket import usocket as socket
except ImportError: except ImportError:
try: try:
import socket import socket
socket_timeout_error = socket.timeout
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
socket = None socket = None
@@ -144,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.
@@ -300,6 +306,12 @@ class Request():
#: Request.max_readline = 16 * 1024 # 16KB lines allowed #: Request.max_readline = 16 * 1024 # 16KB lines allowed
max_readline = 2 * 1024 max_readline = 2 * 1024
#: 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
#: suggestion only, as some platforms may not support it. The default is
#: 1 second.
socket_read_timeout = 1
class G: class G:
pass pass
@@ -392,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
@@ -472,6 +486,9 @@ class Request():
return response return response
return 'Hello, World!' return 'Hello, World!'
Note that the function is not called if the request handler raises an
exception and an error response is returned instead.
""" """
self.after_request_handlers.append(f) self.after_request_handlers.append(f)
return f return f
@@ -515,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
@@ -534,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):
@@ -598,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:
@@ -641,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.
@@ -649,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
@@ -661,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():
@@ -685,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:'):
@@ -746,8 +799,10 @@ class Microdot():
self.url_map = [] self.url_map = []
self.before_request_handlers = [] self.before_request_handlers = []
self.after_request_handlers = [] self.after_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
@@ -783,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
@@ -907,6 +963,24 @@ class Microdot():
self.after_request_handlers.append(f) self.after_request_handlers.append(f)
return f return f
def after_error_request(self, f):
"""Decorator to register a function to run after an error response is
generated. The decorated function must take two arguments, the request
and response objects. The return value of the function must be an
updated response object. The handler is invoked for error responses
generated by Microdot, as well as those returned by application-defined
error handlers.
Example::
@app.after_error_request
def func(request, response):
# ...
return response
"""
self.after_error_request_handlers.append(f)
return f
def errorhandler(self, status_code_or_exception_class): def errorhandler(self, status_code_or_exception_class):
"""Decorator to register a function as an error handler. Error handler """Decorator to register a function as an error handler. Error handler
functions for numeric HTTP status codes must accept a single argument, functions for numeric HTTP status codes must accept a single argument,
@@ -947,6 +1021,8 @@ class Microdot():
self.before_request_handlers.append(handler) self.before_request_handlers.append(handler)
for handler in subapp.after_request_handlers: for handler in subapp.after_request_handlers:
self.after_request_handlers.append(handler) self.after_request_handlers.append(handler)
for handler in subapp.after_error_request_handlers:
self.after_error_request_handlers.append(handler)
for status_code, handler in subapp.error_handlers.items(): for status_code, handler in subapp.error_handlers.items():
self.error_handlers[status_code] = handler self.error_handlers[status_code] = handler
@@ -998,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)
@@ -1049,18 +1125,36 @@ 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 \
hasattr(sock, 'settimeout'): # pragma: no cover
sock.settimeout(Request.socket_read_timeout)
if not hasattr(sock, 'readline'): # pragma: no cover if not hasattr(sock, 'readline'): # pragma: no cover
stream = sock.makefile("rwb") stream = sock.makefile("rwb")
else: else:
@@ -1071,6 +1165,9 @@ class Microdot():
try: try:
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
if exc.errno and exc.errno != errno.ETIMEDOUT:
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)
try: try:
@@ -1094,6 +1191,7 @@ class Microdot():
status_code=res.status_code)) status_code=res.status_code))
def dispatch_request(self, req): def dispatch_request(self, req):
after_request_handled = False
if req: if req:
if req.content_length > req.max_content_length: if req.content_length > req.max_content_length:
if 413 in self.error_handlers: if 413 in self.error_handlers:
@@ -1126,6 +1224,9 @@ class Microdot():
res = handler(req, res) or res res = handler(req, res) or res
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
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:
@@ -1166,6 +1267,10 @@ class Microdot():
res = Response(*res) res = Response(*res)
elif not isinstance(res, Response): elif not isinstance(res, Response):
res = Response(res) res = Response(res)
if not after_request_handled:
for handler in self.after_error_request_handlers:
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:
@@ -93,10 +94,10 @@ class Microdot(BaseMicrodot):
header_list = [] header_list = []
for name, value in res.headers.items(): for name, value in res.headers.items():
if not isinstance(value, list): if not isinstance(value, list):
header_list.append((name, value)) header_list.append((name.lower().encode(), value.encode()))
else: else:
for v in value: for v in value:
header_list.append((name, v)) header_list.append((name.lower().encode(), v.encode()))
if scope['type'] != 'http': # pragma: no cover if scope['type'] != 'http': # pragma: no cover
return return
@@ -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)
@@ -347,6 +353,7 @@ class Microdot(BaseMicrodot):
status_code=res.status_code)) status_code=res.status_code))
async def dispatch_request(self, req): async def dispatch_request(self, req):
after_request_handled = False
if req: if req:
if req.content_length > req.max_content_length: if req.content_length > req.max_content_length:
if 413 in self.error_handlers: if 413 in self.error_handlers:
@@ -383,6 +390,9 @@ class Microdot(BaseMicrodot):
for handler in req.after_request_handlers: for handler in req.after_request_handlers:
res = await self._invoke_handler( res = await self._invoke_handler(
handler, req, res) or res handler, req, res) or res
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)
@@ -425,6 +435,11 @@ class Microdot(BaseMicrodot):
res = Response(*res) res = Response(*res)
elif not isinstance(res, Response): elif not isinstance(res, Response):
res = Response(res) res = Response(res)
if not after_request_handled:
for handler in self.after_error_request_handlers:
res = await self._invoke_handler(
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()
@@ -279,6 +325,39 @@ class TestMicrodot(unittest.TestCase):
self.assertEqual(res.headers['Content-Length'], '3') self.assertEqual(res.headers['Content-Length'], '3')
self.assertEqual(res.text, 'baz') self.assertEqual(res.text, 'baz')
def test_after_error_request(self):
app = Microdot()
@app.after_error_request
def after_error_request_one(req, res):
res.headers['X-One'] = '1'
@app.after_error_request
def after_error_request_two(req, res):
res.set_cookie('foo', 'bar')
return res
@app.route('/foo')
def foo(req):
return 'foo'
client = TestClient(app)
res = client.get('/foo')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertFalse('X-One' in res.headers)
self.assertFalse('Set-Cookie' in res.headers)
res = client.get('/bar')
self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Set-Cookie'], ['foo=bar'])
self.assertEqual(res.headers['X-One'], '1')
self.assertEqual(client.cookies['foo'], 'bar')
def test_400(self): def test_400(self):
self._mock() self._mock()
@@ -661,7 +740,11 @@ class TestMicrodot(unittest.TestCase):
@subapp.after_request @subapp.after_request
def after(req, res): def after(req, res):
return res.body + b':after' res.body += b':after'
@subapp.after_error_request
def after_error(req, res):
res.body += b':errorafter'
@subapp.errorhandler(404) @subapp.errorhandler(404)
def not_found(req): def not_found(req):
@@ -680,7 +763,7 @@ class TestMicrodot(unittest.TestCase):
self.assertEqual(res.status_code, 404) self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.text, '404') self.assertEqual(res.text, '404:errorafter')
res = client.get('/sub/app') res = client.get('/sub/app')
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)

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',
@@ -83,10 +83,10 @@ class TestMicrodotASGI(unittest.TestCase):
if packet['type'] == 'http.response.start': if packet['type'] == 'http.response.start':
self.assertEqual(packet['status'], 200) self.assertEqual(packet['status'], 200)
expected_headers = [ expected_headers = [
('Content-Length', '8'), (b'content-length', b'8'),
('Content-Type', 'text/plain; charset=UTF-8'), (b'content-type', b'text/plain; charset=UTF-8'),
('Set-Cookie', 'foo=foo'), (b'set-cookie', b'foo=foo'),
('Set-Cookie', 'bar=bar; HttpOnly') (b'set-cookie', b'bar=bar; HttpOnly')
] ]
self.assertEqual(len(packet['headers']), len(expected_headers)) self.assertEqual(len(packet['headers']), len(expected_headers))
for header in expected_headers: for header in expected_headers:
@@ -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()
@@ -314,6 +356,39 @@ class TestMicrodotAsync(unittest.TestCase):
self.assertEqual(res.headers['Content-Length'], '3') self.assertEqual(res.headers['Content-Length'], '3')
self.assertEqual(res.text, 'baz') self.assertEqual(res.text, 'baz')
def test_after_error_request(self):
app = Microdot()
@app.after_error_request
def after_error_request_one(req, res):
res.headers['X-One'] = '1'
@app.after_error_request
def after_error_request_two(req, res):
res.set_cookie('foo', 'bar')
return res
@app.route('/foo')
def foo(req):
return 'foo'
client = TestClient(app)
res = self._run(client.get('/foo'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertFalse('X-One' in res.headers)
self.assertFalse('Set-Cookie' in res.headers)
res = self._run(client.get('/bar'))
self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Set-Cookie'], ['foo=bar'])
self.assertEqual(res.headers['X-One'], '1')
self.assertEqual(client.cookies['foo'], 'bar')
def test_400(self): def test_400(self):
self._mock() self._mock()

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

@@ -1,4 +1,4 @@
FROM ubuntu:20.04 FROM ubuntu:22.04
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive

View File

@@ -1,6 +1,10 @@
#!/bin/bash
# this script updates the micropython binary in the /bin directory that is # this script updates the micropython binary in the /bin directory that is
# used to run unit tests under GitHub Actions builds # used to run unit tests under GitHub Actions builds
docker build -t micropython .
docker create -it --name dummy-micropython micropython DOCKER=${DOCKER:-docker}
docker cp dummy-micropython:/usr/local/bin/micropython ../bin/micropython
docker rm dummy-micropython $DOCKER build -t micropython .
$DOCKER create -it --name dummy-micropython micropython
$DOCKER cp dummy-micropython:/usr/local/bin/micropython ../bin/micropython
$DOCKER rm dummy-micropython

10
tox.ini
View File

@@ -1,21 +1,21 @@
[tox] [tox]
envlist=flake8,py36,py37,py38,py39,py310,upy,benchmark envlist=flake8,py37,py38,py39,py310,py311,upy,benchmark
skipsdist=True skipsdist=True
skip_missing_interpreters=True skip_missing_interpreters=True
[gh-actions] [gh-actions]
python = python =
3.6: py36
3.7: py37 3.7: py37
3.8: py38 3.8: py38
3.9: py39 3.9: py39
3.10: py310 3.10: py310
3.11: py311
pypy3: pypy3 pypy3: pypy3
[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
@@ -31,11 +31,11 @@ commands=
flake8 --ignore=W503 --exclude src/utemplate,tests/libs src tests examples flake8 --ignore=W503 --exclude src/utemplate,tests/libs src tests examples
[testenv:upy] [testenv:upy]
whitelist_externals=sh allowlist_externals=sh
commands=sh -c "bin/micropython run_tests.py" commands=sh -c "bin/micropython run_tests.py"
[testenv:upy-mac] [testenv:upy-mac]
whitelist_externals=micropython allowlist_externals=micropython
commands=micropython run_tests.py commands=micropython run_tests.py
deps= deps=