Compare commits

...

84 Commits

Author SHA1 Message Date
Miguel Grinberg
10e740da2b Release 2.5.1 2025-12-21 10:50:34 +00:00
Miguel Grinberg
ba6893ca0f CSRF: accept cross-site request if origin is in the CORS allowed origin list 2025-12-21 10:48:29 +00:00
Miguel Grinberg
a99b658c3f Updated roadman #nolog 2025-12-21 09:46:48 +00:00
Miguel Grinberg
aeffcf82ba Version 2.5.1.dev0 2025-12-21 09:45:50 +00:00
Miguel Grinberg
a862a3353b Release 2.5.0 2025-12-21 09:45:19 +00:00
Miguel Grinberg
fb7aeac2ac Fix minor documentation mistake #nolog 2025-12-21 09:42:45 +00:00
Miguel Grinberg
baf02ae781 new documentation template #nolog 2025-12-21 00:06:49 +00:00
Miguel Grinberg
0bae4c9477 CSRF protection (#335) 2025-12-20 19:43:08 +00:00
Miguel Grinberg
053b8a8138 Add Login.get_current_user helper method 2025-12-20 00:13:41 +00:00
dependabot[bot]
220d4cd92a Bump urllib3 from 2.5.0 to 2.6.0 in /examples/benchmark (#334) #nolog
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.5.0 to 2.6.0.
- [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/2.5.0...2.6.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.6.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 09:52:32 +00:00
Miguel Grinberg
1c7020ca1a Add scheme to the request 2025-11-26 00:42:26 +00:00
Miguel Grinberg
3b77e5d6a1 Updated roadmap #nolog 2025-11-23 20:03:23 +00:00
Miguel Grinberg
f128b3ded4 Add ASGI lifespan events (Fixes #322) 2025-11-23 00:08:29 +00:00
Miguel Grinberg
ae9f237ce6 Version 2.4.1.dev0 2025-11-08 12:30:39 +00:00
Miguel Grinberg
4c2d2896c3 Release 2.4.0 2025-11-08 12:28:33 +00:00
Miguel Grinberg
38f5a27b33 adding version to __init__.py file (Fixes #312) 2025-11-08 12:19:32 +00:00
Miguel Grinberg
d0808efa6b SSE: add support for retry and comments 2025-11-08 12:01:05 +00:00
Miguel Grinberg
ce9de6e37a Ignore "muted" errors during request creation 2025-11-07 00:05:21 +00:00
Miguel Grinberg
d61785b2e8 Ignore expires and max_age arguments if passed to Response.delete_cookie (Fixes #323) 2025-11-04 10:03:39 +00:00
Miguel Grinberg
680cbefc19 Version 2.3.6.dev0 2025-10-18 00:11:10 +01:00
Miguel Grinberg
2d4189100a Release 2.3.5 2025-10-18 00:10:45 +01:00
Miguel Grinberg
27fc03f100 Remove unused instance variable in Microdot class 2025-10-18 00:09:33 +01:00
Miguel Grinberg
f70c524fb0 always encode ASGI response bodies to bytes 2025-10-18 00:05:09 +01:00
dependabot[bot]
79897e7980 Bump h2 from 4.1.0 to 4.3.0 in /examples/benchmark (#319) #nolog
Bumps [h2](https://github.com/python-hyper/h2) from 4.1.0 to 4.3.0.
- [Changelog](https://github.com/python-hyper/h2/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/python-hyper/h2/compare/v4.1.0...v4.3.0)

---
updated-dependencies:
- dependency-name: h2
  dependency-version: 4.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 11:17:29 +01:00
Miguel Grinberg
7edc7c3a38 Version 2.3.5.dev0 2025-10-16 00:22:40 +01:00
Miguel Grinberg
84361045a3 Release 2.3.4 2025-10-16 00:21:49 +01:00
Miguel Grinberg
e9c9937b41 Add Python 3.13 and 3.14 to the CI builds 2025-10-16 00:17:30 +01:00
Miguel Grinberg
7addcf4bb5 Faster HTTP streaming when using ASGI (#318) 2025-10-16 00:17:17 +01:00
Miguel Grinberg
6045390cef Prevent reading past EOF in multipart parser (Fixes #307) (#309) 2025-09-03 15:24:04 +01:00
Miguel Grinberg
c12d465809 Parse empty cookies (Fixes #308) 2025-08-13 23:30:09 +01:00
Miguel Grinberg
cca0b0f693 Generate a valid CORS response when the request badly formatted (Fixes #305) 2025-07-15 22:53:03 +01:00
Miguel Grinberg
7071358b1f Add weather dashboard example (#303) 2025-07-13 23:46:31 +01:00
Miguel Grinberg
d7fcd1a247 Version 2.3.4.dev0 2025-07-01 23:48:57 +01:00
Miguel Grinberg
eb5e249e34 Release 2.3.3 2025-07-01 23:46:00 +01:00
Miguel Grinberg
9bc3dced6c Handle partial reads in WebSocket class (Fixes #294) 2025-06-30 18:32:21 +01:00
Miguel Grinberg
786e5e5337 Additional documentation for the URLPattern class 2025-06-30 18:23:46 +01:00
Ozuba
1d419ce59b Add svg to supported mimetypes (#302) 2025-06-30 12:24:24 +01:00
Miguel Grinberg
7c98c4589d Additional documentation on WebSocket and SSE disconnections 2025-06-28 11:01:22 +01:00
Miguel Grinberg
0f219fd494 fix linter errors #nolog 2025-06-28 10:48:20 +01:00
Miguel Grinberg
e146e2d08d More detailed documentation for current_user 2025-06-28 10:40:59 +01:00
Miguel Grinberg
dc61470fa9 More detailed documentation for route responses 2025-06-28 10:40:30 +01:00
Miguel Grinberg
d7a9c53563 Add a sub-application example 2025-06-20 23:59:04 +01:00
dependabot[bot]
4ddb09ceb3 Bump urllib3 from 2.2.2 to 2.5.0 in /examples/benchmark (#301) #nolog
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.2 to 2.5.0.
- [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/2.2.2...2.5.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 09:20:41 +01:00
Miguel Grinberg
3dffa05ffb Documentation improvements for the Request class 2025-06-18 20:09:59 +01:00
dependabot[bot]
b93a55c9f2 Bump requests from 2.32.0 to 2.32.4 in /examples/benchmark (#300) #nolog
Bumps [requests](https://github.com/psf/requests) from 2.32.0 to 2.32.4.
- [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.32.0...v2.32.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 10:16:45 +01:00
Miguel Grinberg
f5d3d931ed Support for SSE responses in the test client 2025-05-18 18:26:38 +01:00
Miguel Grinberg
654a85f46b Do not silence exceptions that occur in the SSE task 2025-05-18 12:21:17 +01:00
Miguel Grinberg
3c936a82e0 Version 2.3.3.dev0 2025-05-08 23:11:35 +01:00
Miguel Grinberg
4c0ace1b01 Release 2.3.2 2025-05-08 23:02:29 +01:00
Miguel Grinberg
d9d7ff0825 use async error handlers in auth module (Fixes #298) 2025-05-08 20:07:35 +01:00
dependabot[bot]
7c42a18436 Bump h11 from 0.14.0 to 0.16.0 in /examples/benchmark (#293) #nolog
Bumps [h11](https://github.com/python-hyper/h11) from 0.14.0 to 0.16.0.
- [Commits](https://github.com/python-hyper/h11/compare/v0.14.0...v0.16.0)

---
updated-dependencies:
- dependency-name: h11
  dependency-version: 0.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-24 19:04:29 +01:00
Miguel Grinberg
ea84fcb435 Version 2.3.2.dev0 2025-04-13 00:01:21 +01:00
Miguel Grinberg
f30c4733f0 Release 2.3.1 2025-04-13 00:01:12 +01:00
Miguel Grinberg
cd0b3234dd Additional support needed when using orjson 2025-04-12 23:58:48 +01:00
Miguel Grinberg
1f64478957 Version 2.3.1.dev0 2025-04-12 23:33:26 +01:00
Miguel Grinberg
815594fc8b Release 2.3.0 2025-04-12 23:31:54 +01:00
Miguel Grinberg
086f2af3de Use orjson instead of json if available 2025-04-12 23:24:31 +01:00
Miguel Grinberg
f317b15bdb Support optional authentication methods 2025-04-06 23:52:36 +01:00
Miguel Grinberg
b6f232db11 Addressed typing warnings from pyright 2025-04-06 23:52:36 +01:00
Miguel Grinberg
e7ee74d6bb Catch SSL crashes while writing the response (Fixes #206) 2025-03-22 19:02:06 +00:00
dependabot[bot]
847dfd1321 Bump gunicorn from 22.0.0 to 23.0.0 in /examples/benchmark (#291) #nolog
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 22.0.0 to 23.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/22.0.0...23.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-22 12:41:50 +00:00
Miguel Grinberg
1aa035378e Updates to change log #nolog 2025-03-22 12:40:27 +00:00
Miguel Grinberg
1edfb8daa7 Version 2.2.1.dev0 2025-03-22 12:37:02 +00:00
Miguel Grinberg
9337a2ec9b Release 2.2.0 2025-03-22 12:35:02 +00:00
Miguel Grinberg
11a91a6035 Support for multipart/form-data requests (#287) 2025-03-22 12:24:12 +00:00
Miguel Grinberg
99f65c0198 Additional urldecode tests 2025-03-16 20:39:50 +00:00
Miguel Grinberg
4cc2e95338 Update micropython version used in tests to 1.24.1 2025-03-16 20:34:38 +00:00
Miguel Grinberg
d203df75fe urldecoding should always be done in bytes 2025-03-16 20:32:34 +00:00
dependabot[bot]
00bf535821 Bump jinja2 from 3.1.5 to 3.1.6 in /examples/benchmark (#286) #nolog
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.5 to 3.1.6.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.5...3.1.6)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-06 10:19:46 +00:00
Miguel Grinberg
3bc31f10b2 Simplified urldecode logic 2025-03-03 19:16:18 +00:00
Miguel Grinberg
aa76e6378b Delay route compilation to allow late register_type calls 2025-03-03 19:10:33 +00:00
Miguel Grinberg
c6b99b6d81 Documentation improvements 2025-03-02 19:47:21 +00:00
Miguel Grinberg
953dd94321 Expose the Jinja environment as Template.jinja_env 2025-03-02 11:53:54 +00:00
Miguel Grinberg
68a53a7ae7 Update README #nolog 2025-03-02 00:51:23 +00:00
Miguel Grinberg
c92b5ae282 Redesigned the URL parser to allow for custom path components 2025-03-02 00:48:07 +00:00
dependabot[bot]
48ce31e699 Bump quart from 0.19.7 to 0.20.0 in /examples/benchmark (#283) #nolog
Bumps [quart](https://github.com/pallets/quart) from 0.19.7 to 0.20.0.
- [Release notes](https://github.com/pallets/quart/releases)
- [Changelog](https://github.com/pallets/quart/blob/main/CHANGES.md)
- [Commits](https://github.com/pallets/quart/compare/0.19.7...0.20.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-04 11:19:19 +00:00
dependabot[bot]
6a33e817a2 Bump jinja2 from 3.1.4 to 3.1.5 in /examples/benchmark (#284) #nolog
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.5)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-04 11:18:55 +00:00
Miguel Grinberg
265009ecd6 Version 2.1.1.dev0 2025-02-04 00:35:10 +00:00
Miguel Grinberg
2efbd67878 Release 2.1.0 2025-02-04 00:31:06 +00:00
Miguel Grinberg
d807011ad0 user logins 2025-02-04 00:04:55 +00:00
Miguel Grinberg
675c978797 Basic and token authentication support 2025-02-03 20:00:36 +00:00
Miguel Grinberg
cd87abba30 Mount unit tests 2025-02-03 11:06:26 +00:00
Miguel Grinberg
fd7931e1ae Added Request.url_prefix, Reques.subapp and local mounts 2025-02-03 00:33:59 +00:00
Maxi
d487a73c1e add js to sse example (#281) 2025-01-22 23:42:51 +00:00
106 changed files with 5623 additions and 1666 deletions

View File

@@ -22,7 +22,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ['3.8', '3.9', '3.10', '3.11', '3.12']
python: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
fail-fast: false
runs-on: ${{ matrix.os }}
steps:

View File

@@ -1,5 +1,84 @@
# Microdot change log
**Release 2.5.1** - 2025-12-21
- CSRF: accept cross-site request if origin is in the CORS allowed origin list ([commit](https://github.com/miguelgrinberg/microdot/commit/ba6893ca0fb3c3dd18cf934f8eee893cc2a10daa))
**Release 2.5.0** - 2025-12-21
- CSRF protection [#335](https://github.com/miguelgrinberg/microdot/issues/335) ([commit](https://github.com/miguelgrinberg/microdot/commit/0bae4c9477e9fdb231d1979cc6ed26c31e12b1aa))
- Added support for ASGI lifespan events [#322](https://github.com/miguelgrinberg/microdot/issues/322) ([commit](https://github.com/miguelgrinberg/microdot/commit/f128b3ded45ccd418a00d199769240342a613b5e))
- Added `scheme` and `route` attributes to the request object ([commit](https://github.com/miguelgrinberg/microdot/commit/1c7020ca1a3e5a6a1549dc52de38a0b7fd0a439a))
- Added `Login.get_current_user()` helper method ([commit](https://github.com/miguelgrinberg/microdot/commit/053b8a81380fcdf285592a32e6b590ee50b7d048))
**Release 2.4.0** - 2025-11-08
- SSE: Add support for the retry command and keepalive comments ([commit](https://github.com/miguelgrinberg/microdot/commit/d0808efa6b32e00992596f1bb3d4c3a372df2168))
- Ignore `expires` and `max_age` arguments if passed to `Response.delete_cookie` [#323](https://github.com/miguelgrinberg/microdot/issues/323) ([commit](https://github.com/miguelgrinberg/microdot/commit/d61785b2e8d18438e5031de9c49e61642e5cfb3f))
- Ignore "muted" errors during request creation ([commit](https://github.com/miguelgrinberg/microdot/commit/ce9de6e37a6323664eb7666b817932f371f1e099))
- Add package version to `microdot/__init__.py` file [#312](https://github.com/miguelgrinberg/microdot/issues/312) ([commit](https://github.com/miguelgrinberg/microdot/commit/38f5a27b33c7968fc7414b67742e034e2b9a09ca))
**Release 2.3.5** - 2025-10-18
- Always encode ASGI response bodies to bytes ([commit](https://github.com/miguelgrinberg/microdot/commit/f70c524fb0bdc8c5fef2223c82f5e339445bc5fa))
- Remove unused instance variable in `Microdot` class ([commit](https://github.com/miguelgrinberg/microdot/commit/27fc03f10047e4483f8d19559025d728b14a27c8))
**Release 2.3.4** - 2025-10-16
- Prevent reading past EOF in multipart parser [#309](https://github.com/miguelgrinberg/microdot/issues/309) ([commit](https://github.com/miguelgrinberg/microdot/commit/6045390cef8735cbbc9f5f7eee7a3912f00e284d))
- Generate a valid CORS response when the request is badly formatted [#305](https://github.com/miguelgrinberg/microdot/issues/305) ([commit](https://github.com/miguelgrinberg/microdot/commit/cca0b0f693c909134bc19eb41dfb5a86226e032b))
- Faster HTTP streaming when using ASGI [#318](https://github.com/miguelgrinberg/microdot/issues/318) ([commit](https://github.com/miguelgrinberg/microdot/commit/7addcf4bb51f1caf57663c5bb4d8cc16ee6391e1))
- Parse empty cookies [#308](https://github.com/miguelgrinberg/microdot/issues/308) ([commit](https://github.com/miguelgrinberg/microdot/commit/c12d4658091ff7eec1ac67c83bcd51eb38af9db7))
- Add weather dashboard example [#303](https://github.com/miguelgrinberg/microdot/issues/303) ([commit](https://github.com/miguelgrinberg/microdot/commit/7071358b1f95892b1342226b43411e036be67d3a))
- Add Python 3.13 and 3.14 to the CI builds ([commit](https://github.com/miguelgrinberg/microdot/commit/e9c9937b41e652876241307307f3e855f4f07379))
**Release 2.3.3** - 2025-07-01
- Handle partial reads in WebSocket class [#294](https://github.com/miguelgrinberg/microdot/issues/294) ([commit](https://github.com/miguelgrinberg/microdot/commit/9bc3dced6c1f582dde0496961d25170b448ad8d7))
- Add SVG to supported mimetypes [#302](https://github.com/miguelgrinberg/microdot/issues/302) ([commit](https://github.com/miguelgrinberg/microdot/commit/1d419ce59bf7006617109c05dc2d6fc6d1dc8235)) (thanks **Ozuba**!)
- Do not silence exceptions that occur in the SSE task ([commit](https://github.com/miguelgrinberg/microdot/commit/654a85f46b7dd7a1e94f81193c4a78a8a1e99936))
- Add Support for SSE responses in the test client ([commit](https://github.com/miguelgrinberg/microdot/commit/f5d3d931edfbacedebf5fdf938ef77c5ee910380))
- Documentation improvements for the `Request` class ([commit](https://github.com/miguelgrinberg/microdot/commit/3dffa05ffb229813156b71e10a85283bdaa26d5e))
- Additional documentation for the `URLPattern` class ([commit](https://github.com/miguelgrinberg/microdot/commit/786e5e533748e1343612c97123773aec9a1a99fc))
- More detailed documentation for route responses ([commit](https://github.com/miguelgrinberg/microdot/commit/dc61470fa959549bb43313906ba6ed9f686babc2))
- Additional documentation on WebSocket and SSE disconnections ([commit](https://github.com/miguelgrinberg/microdot/commit/7c98c4589de4774a88381b393444c75094532550))
- More detailed documentation for `current_user` ([commit](https://github.com/miguelgrinberg/microdot/commit/e146e2d08deddf9b924c7657f04db28d71f34221))
- Add a sub-application example ([commit](https://github.com/miguelgrinberg/microdot/commit/d7a9c535639268e415714b12ac898ae38e516308))
**Release 2.3.2** - 2025-05-08
- Use async error handlers in auth module [#298](https://github.com/miguelgrinberg/microdot/issues/298) ([commit](https://github.com/miguelgrinberg/microdot/commit/d9d7ff0825e4c5fbed6564d3684374bf3937df11))
**Release 2.3.1** - 2025-04-13
- Additional support needed when using `orjson` ([commit](https://github.com/miguelgrinberg/microdot/commit/cd0b3234ddb0c8ff4861d369836ec2aed77494db))
**Release 2.3.0** - 2025-04-12
- Support optional authentication methods ([commit](https://github.com/miguelgrinberg/microdot/commit/f317b15bdbf924007e5e3414e0c626baccc3ede6))
- Catch SSL exceptions while writing the response [#206](https://github.com/miguelgrinberg/microdot/issues/206) ([commit](https://github.com/miguelgrinberg/microdot/commit/e7ee74d6bba74cfd89b9ddc38f28e02514eb1791))
- Use `orjson` instead of `json` if available ([commit](https://github.com/miguelgrinberg/microdot/commit/086f2af3deab86d4340f3f1feb9e019de59f351d))
- Addressed typing warnings from pyright ([commit](https://github.com/miguelgrinberg/microdot/commit/b6f232db1125045d79c444c736a2ae59c5501fdd))
**Release 2.2.0** - 2025-03-22
- Support for `multipart/form-data` requests [#287](https://github.com/miguelgrinberg/microdot/issues/287) ([commit](https://github.com/miguelgrinberg/microdot/commit/11a91a60350518e426b557fae8dffe75912f8823))
- Support custom path components in URLs ([commit #1](https://github.com/miguelgrinberg/microdot/commit/c92b5ae28222af5a1094f5d2f70a45d4d17653d5) [commit #2](https://github.com/miguelgrinberg/microdot/commit/aa76e6378b37faab52008a8aab8db75f81b29323))
- Expose the Jinja environment as `Template.jinja_env` ([commit](https://github.com/miguelgrinberg/microdot/commit/953dd9432122defe943f0637bbe7e01f2fc7743f))
- Simplified urldecode logic ([commit #1](https://github.com/miguelgrinberg/microdot/commit/3bc31f10b2b2d4460c62366013278d87665f0f97) [commit #2](https://github.com/miguelgrinberg/microdot/commit/d203df75fef32c7cc0fe7cc6525e77522b37a289))
- Additional urldecode tests ([commit](https://github.com/miguelgrinberg/microdot/commit/99f65c0198590c0dfb402c24685b6f8dfba1935d))
- Documentation improvements ([commit](https://github.com/miguelgrinberg/microdot/commit/c6b99b6d8117d4e40e16d5b953dbf4deb023d24d))
- Update micropython version used in tests to 1.24.1 ([commit](https://github.com/miguelgrinberg/microdot/commit/4cc2e95338a7de3b03742389004147ee21285621))
**Release 2.1.0** - 2025-02-04
- User login support ([commit](https://github.com/miguelgrinberg/microdot/commit/d807011ad006e53e70c4594d7eac04d03bb08681))
- Basic and token authentication support ([commit](https://github.com/miguelgrinberg/microdot/commit/675c9787974da926af446974cd96ef224e0ee27f))
- Added `local` argument to the `app.mount()` method, to define sub-application specific before and after request handlers ([commit](https://github.com/miguelgrinberg/microdot/commit/fd7931e1aec173c60f81dad18c1a102ed8f0e081))
- Added `Request.url_prefix`, `Request.subapp` and local mounts ([commit](https://github.com/miguelgrinberg/microdot/commit/fd7931e1aec173c60f81dad18c1a102ed8f0e081))
- Added a front end to the SSE example [#281](https://github.com/miguelgrinberg/microdot/issues/281) ([commit](https://github.com/miguelgrinberg/microdot/commit/d487a73c1ea5b3467e23907618b348ca52e0235c)) (thanks **Maxi**!)
- Additional ``app.mount()`` unit tests ([commit](https://github.com/miguelgrinberg/microdot/commit/cd87abba30206ec6d3928e0aabacb2fccf7baf70))
**Release 2.0.7** - 2024-11-10
- Accept responses with just a status code [#263](https://github.com/miguelgrinberg/microdot/issues/263) ([commit #1](https://github.com/miguelgrinberg/microdot/commit/4eac013087f807cafa244b8a6b7b0ed4c82ff150) [commit #2](https://github.com/miguelgrinberg/microdot/commit/c46e4291061046f1be13f300dd08645b71c16635))

View File

@@ -19,38 +19,25 @@ async def index(request):
app.run()
```
## Migrating to Microdot 2
Version 2 of Microdot incorporates feedback received from users of earlier
releases, and attempts to improve and correct some design decisions that have
proven to be problematic.
For this reason most applications built for earlier versions will need to be
updated to work correctly with Microdot 2. The
[Migration Guide](https://microdot.readthedocs.io/en/stable/migrating.html)
describes the backwards incompatible changes that were made.
## Resources
- [Change Log](https://github.com/miguelgrinberg/microdot/blob/main/CHANGES.md)
- Documentation
- [Latest](https://microdot.readthedocs.io/en/latest/)
- [Stable (v2)](https://microdot.readthedocs.io/en/stable/)
- [Legacy (v1)](https://microdot.readthedocs.io/en/v1/) ([Code](https://github.com/miguelgrinberg/microdot/tree/v1))
- Legacy (v1)
- [Code](https://github.com/miguelgrinberg/microdot/tree/v1)
- [Documentation](https://microdot.readthedocs.io/en/v1/)
## Roadmap
The following features are planned for future releases of Microdot, both for
MicroPython and CPython:
- Support for forms encoded in `multipart/form-data` format
- Authentication support, similar to [Flask-Login](https://github.com/maxcountryman/flask-login) for Flask
- Authentication support, similar to [Flask-Login](https://github.com/maxcountryman/flask-login) for Flask (**Added in version 2.1**)
- Support for forms encoded in `multipart/form-data` format (**Added in version 2.2**)
- CSRF protection extension (**Added in version 2.5**)
- Pub/sub mini-framework for WebSocket and SSE
- OpenAPI integration, similar to [APIFairy](https://github.com/miguelgrinberg/apifairy) for Flask
In addition to the above, the following extensions are also under consideration,
but only for CPython:
- Database integration through [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy)
- Socket.IO support through [python-socketio](https://github.com/miguelgrinberg/python-socketio)
Do you have other ideas to propose? Let's [discuss them](https://github.com/miguelgrinberg/microdot/discussions/new?category=ideas)!
Do you have other ideas to propose? Let's [discuss them](https://github.com/:miguelgrinberg/microdot/discussions/new?category=ideas)!

Binary file not shown.

View File

@@ -1,71 +0,0 @@
API Reference
=============
Core API
--------
.. autoclass:: microdot.Microdot
:members:
.. autoclass:: microdot.Request
:members:
.. autoclass:: microdot.Response
:members:
WebSocket
---------
.. automodule:: microdot.websocket
:members:
Server-Sent Events (SSE)
------------------------
.. automodule:: microdot.sse
:members:
Templates (uTemplate)
---------------------
.. automodule:: microdot.utemplate
:members:
Templates (Jinja)
-----------------
.. automodule:: microdot.jinja
:members:
User Sessions
-------------
.. automodule:: microdot.session
:members:
Cross-Origin Resource Sharing (CORS)
------------------------------------
.. automodule:: microdot.cors
:members:
Test Client
-----------
.. automodule:: microdot.test_client
:members:
ASGI
----
.. autoclass:: microdot.asgi.Microdot
:members:
:exclude-members: shutdown, run
WSGI
----
.. autoclass:: microdot.wsgi.Microdot
:members:
:exclude-members: shutdown, run

6
docs/api/asgi.rst Normal file
View File

@@ -0,0 +1,6 @@
ASGI
----
.. autoclass:: microdot.asgi.Microdot
:members:
:exclude-members: shutdown, run

7
docs/api/auth.rst Normal file
View File

@@ -0,0 +1,7 @@
Authentication
--------------
.. automodule:: microdot.auth
:inherited-members:
:special-members: __call__
:members:

5
docs/api/cors.rst Normal file
View File

@@ -0,0 +1,5 @@
Cross-Origin Resource Sharing (CORS)
------------------------------------
.. automodule:: microdot.cors
:members:

5
docs/api/csrf.rst Normal file
View File

@@ -0,0 +1,5 @@
Cross-Site Request Forgery (CSRF) Protection
--------------------------------------------
.. automodule:: microdot.csrf
:members:

21
docs/api/index.rst Normal file
View File

@@ -0,0 +1,21 @@
API Reference
=============
.. toctree::
:maxdepth: 1
microdot
multipart
websocket
sse
utemplate
jinja
sessions
auth
login
cors
csrf
test_client
asgi
wsgi

5
docs/api/jinja.rst Normal file
View File

@@ -0,0 +1,5 @@
Templates (Jinja)
-----------------
.. automodule:: microdot.jinja
:members:

7
docs/api/login.rst Normal file
View File

@@ -0,0 +1,7 @@
User Logins
-----------
.. automodule:: microdot.login
:inherited-members:
:special-members: __call__
:members:

14
docs/api/microdot.rst Normal file
View File

@@ -0,0 +1,14 @@
Core API
--------
.. autoclass:: microdot.Microdot
:members:
.. autoclass:: microdot.Request
:members:
.. autoclass:: microdot.Response
:members:
.. autoclass:: microdot.URLPattern
:members:

5
docs/api/multipart.rst Normal file
View File

@@ -0,0 +1,5 @@
Multipart Forms
---------------
.. automodule:: microdot.multipart
:members:

5
docs/api/sessions.rst Normal file
View File

@@ -0,0 +1,5 @@
User Sessions
-------------
.. automodule:: microdot.session
:members:

5
docs/api/sse.rst Normal file
View File

@@ -0,0 +1,5 @@
Server-Sent Events (SSE)
------------------------
.. automodule:: microdot.sse
:members:

5
docs/api/test_client.rst Normal file
View File

@@ -0,0 +1,5 @@
Test Client
-----------
.. automodule:: microdot.test_client
:members:

5
docs/api/utemplate.rst Normal file
View File

@@ -0,0 +1,5 @@
Templates (uTemplate)
---------------------
.. automodule:: microdot.utemplate
:members:

5
docs/api/websocket.rst Normal file
View File

@@ -0,0 +1,5 @@
WebSocket
---------
.. automodule:: microdot.websocket
:members:

6
docs/api/wsgi.rst Normal file
View File

@@ -0,0 +1,6 @@
WSGI
----
.. autoclass:: microdot.wsgi.Microdot
:members:
:exclude-members: shutdown, run

View File

@@ -46,7 +46,8 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
html_theme = 'furo'
html_title = 'Microdot'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
@@ -58,12 +59,6 @@ html_css_files = [
]
html_theme_options = {
'github_user': 'miguelgrinberg',
'github_repo': 'microdot',
'github_banner': True,
'github_button': True,
'github_type': 'star',
'fixed_sidebar': True,
}
autodoc_default_options = {

7
docs/contributing.rst Normal file
View File

@@ -0,0 +1,7 @@
Contributing
------------
Thank you for your interest in Microdot!
Please visit the `GitHub repository <https://github.com/miguelgrinberg/microdot>`_
to learn about the project and find open issues and pull requests.

View File

@@ -1,452 +0,0 @@
Core Extensions
---------------
Microdot is a highly extensible web application framework. The extensions
described in this section are maintained as part of the Microdot project in
the same source code repository.
WebSocket Support
~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `websocket.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/websocket.py>`_
* - Required external dependencies
- | None
* - Examples
- | `echo.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo.py>`_
The WebSocket extension gives the application the ability to handle WebSocket
requests. The :func:`with_websocket <microdot.websocket.with_websocket>`
decorator is used to mark a route handler as a WebSocket handler. Decorated
routes receive a WebSocket object as a second argument. The WebSocket object
provides ``send()`` and ``receive()`` asynchronous methods to send and receive
messages respectively.
Example::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
Server-Sent Events Support
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `sse.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/sse.py>`_
* - Required external dependencies
- | None
* - Examples
- | `counter.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/sse/counter.py>`_
The Server-Sent Events (SSE) extension simplifies the creation of a streaming
endpoint that follows the SSE web standard. The :func:`with_sse <microdot.sse.with_sse>`
decorator is used to mark a route as an SSE handler. Decorated routes receive
an SSE object as second argument. The SSE object provides a ``send()``
asynchronous method to send an event to the client.
Example::
@app.route('/events')
@with_sse
async def events(request, sse):
for i in range(10):
await asyncio.sleep(1)
await sse.send({'counter': i}) # unnamed event
await sse.send('end', event='comment') # named event
.. note::
The SSE protocol is unidirectional, so there is no ``receive()`` method in
the SSE object. For bidirectional communication with the client, use the
WebSocket extension.
Rendering Templates
~~~~~~~~~~~~~~~~~~~
Many web applications use HTML templates for rendering content to clients.
Microdot includes extensions to render templates with the
`utemplate <https://github.com/pfalcon/utemplate>`_ package on CPython and
MicroPython, and with `Jinja <https://jinja.palletsprojects.com/>`_ only on
CPython.
Using the uTemplate Engine
^^^^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `utemplate.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/utemplate.py>`_
* - Required external dependencies
- | `utemplate <https://github.com/pfalcon/utemplate/tree/master/utemplate>`_
* - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello.py>`_
The :class:`Template <microdot.utemplate.Template>` class is used to load a
template. The argument is the template filename, relative to the templates
directory, which is *templates* by default.
The ``Template`` object has a :func:`render() <microdot.utemplate.Template.render>`
method that renders the template to a string. This method receives any
arguments that are used by the template.
Example::
from microdot.utemplate import Template
@app.get('/')
async def index(req):
return Template('index.html').render()
The ``Template`` object also has a :func:`generate() <microdot.utemplate.Template.generate>`
method, which returns a generator instead of a string. The
:func:`render_async() <microdot.utemplate.Template.render_async>` and
:func:`generate_async() <microdot.utemplate.Template.generate_async>` methods
are the asynchronous versions of these two methods.
The default location from where templates are loaded is the *templates*
subdirectory. This location can be changed with the
:func:`Template.initialize <microdot.utemplate.Template.initialize>` class
method::
Template.initialize('my_templates')
By default templates are automatically compiled the first time they are
rendered, or when their last modified timestamp is more recent than the
compiledo file's timestamp. This loading behavior can be changed by switching
to a different template loader. For example, if the templates are pre-compiled,
the timestamp check and compile steps can be removed by switching to the
"compiled" template loader::
from utemplate import compiled
from microdot.utemplate import Template
Template.initialize(loader_class=compiled.Loader)
Consult the `uTemplate documentation <https://github.com/pfalcon/utemplate>`_
for additional information regarding template loaders.
Using the Jinja Engine
^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython only
* - Required Microdot source files
- | `jinja.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/jinja.py>`_
* - Required external dependencies
- | `Jinja2 <https://jinja.palletsprojects.com/>`_
* - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello.py>`_
The :class:`Template <microdot.jinja.Template>` class is used to load a
template. The argument is the template filename, relative to the templates
directory, which is *templates* by default.
The ``Template`` object has a :func:`render() <microdot.jinja.Template.render>`
method that renders the template to a string. This method receives any
arguments that are used by the template.
Example::
from microdot.jinja import Template
@app.get('/')
async def index(req):
return Template('index.html').render()
The ``Template`` object also has a :func:`generate() <microdot.jinja.Template.generate>`
method, which returns a generator instead of a string.
The default location from where templates are loaded is the *templates*
subdirectory. This location can be changed with the
:func:`Template.initialize <microdot.jinja.Template.initialize>` class method::
Template.initialize('my_templates')
The ``initialize()`` method also accepts ``enable_async`` argument, which
can be set to ``True`` if asynchronous rendering of templates is desired. If
this option is enabled, then the
:func:`render_async() <microdot.jinja.Template.render_async>` and
:func:`generate_async() <microdot.jinja.Template.generate_async>` methods
must be used.
.. note::
The Jinja extension is not compatible with MicroPython.
Maintaining Secure User Sessions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `session.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/session.py>`_
* - Required external dependencies
- | CPython: `PyJWT <https://pyjwt.readthedocs.io/>`_
| MicroPython: `jwt.py <https://github.com/micropython/micropython-lib/blob/master/python-ecosys/pyjwt/jwt.py>`_,
`hmac.py <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/hmac/hmac.py>`_
* - Examples
- | `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
user sessions. The session data is stored as a signed cookie in the client's
browser, in `JSON Web Token (JWT) <https://en.wikipedia.org/wiki/JSON_Web_Token>`_
format.
To work with user sessions, the application first must configure a secret key
that will be used to sign the session cookies. It is very important that this
key is kept secret, as its name implies. An attacker who is in possession of
this key can generate valid user session cookies with any contents.
To initialize the session extension and configure the secret key, create a
:class:`Session <microdot.session.Session>` object::
Session(app, secret_key='top-secret')
The :func:`with_session <microdot.session.with_session>` decorator is the
most convenient way to retrieve the session at the start of a request::
from microdot import Microdot, redirect
from microdot.session import Session, with_session
app = Microdot()
Session(app, secret_key='top-secret')
@app.route('/', methods=['GET', 'POST'])
@with_session
async def index(req, session):
username = session.get('username')
if req.method == 'POST':
username = req.form.get('username')
session['username'] = username
session.save()
return redirect('/')
if username is None:
return 'Not logged in'
else:
return 'Logged in as ' + username
@app.post('/logout')
@with_session
async def logout(req, session):
session.delete()
return redirect('/')
The :func:`save() <microdot.session.SessionDict.save>` and
:func:`delete() <microdot.session.SessionDict.delete>` methods are used to update
and destroy the user session respectively.
Cross-Origin Resource Sharing (CORS)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `cors.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/cors.py>`_
* - Required external dependencies
- | None
* - Examples
- | `app.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/cors/app.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)
Testing with the Test Client
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `test_client.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/test_client.py>`_
* - Required external dependencies
- | None
The Microdot Test Client is a utility class that can be used in tests to send
requests into the application without having to start a web server.
Example::
from microdot import Microdot
from microdot.test_client import TestClient
app = Microdot()
@app.route('/')
def index(req):
return 'Hello, World!'
async def test_app():
client = TestClient(app)
response = await client.get('/')
assert response.text == 'Hello, World!'
See the documentation for the :class:`TestClient <microdot.test_client.TestClient>`
class for more details.
Deploying on a Production Web Server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``Microdot`` class creates its own simple web server. This is enough for an
application deployed with MicroPython, but when using CPython it may be useful
to use a separate, battle-tested web server. To address this need, Microdot
provides extensions that implement the ASGI and WSGI protocols.
Using an ASGI Web Server
^^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython only
* - Required Microdot source files
- | `asgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/asgi.py>`_
* - Required external dependencies
- | An ASGI web server, such as `Uvicorn <https://www.uvicorn.org/>`_.
* - Examples
- | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_asgi.py>`_
| `hello_asgi.py (uTemplate) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello_asgi.py>`_
| `hello_asgi.py (Jinja) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello_asgi.py>`_
| `echo_asgi.py (WebSocket) <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_asgi.py>`_
The ``asgi`` module provides an extended ``Microdot`` class that
implements the ASGI protocol and can be used with a compliant ASGI server such
as `Uvicorn <https://www.uvicorn.org/>`_.
To use an ASGI web server, the application must import the
:class:`Microdot <microdot.asgi.Microdot>` class from the ``asgi`` module::
from microdot.asgi import Microdot
app = Microdot()
@app.route('/')
async def index(req):
return 'Hello, World!'
The ``app`` application instance created from this class can be used as the
ASGI callable with any complaint ASGI web server. If the above example
application was stored in a file called *test.py*, then the following command
runs the web application using the Uvicorn web server::
uvicorn test:app
When using the ASGI support, the ``scope`` dictionary provided by the web
server is available to request handlers as ``request.asgi_scope``.
Using a WSGI Web Server
^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython only
* - Required Microdot source files
- | `wsgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/wsgi.py>`_
* - Required external dependencies
- | A WSGI web server, such as `Gunicorn <https://gunicorn.org/>`_.
* - Examples
- | `hello_wsgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_wsgi.py>`_
| `hello_wsgi.py (uTemplate) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello_wsgi.py>`_
| `hello_wsgi.py (Jinja) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello_wsgi.py>`_
| `echo_wsgi.py (WebSocket) <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_wsgi.py>`_
The ``wsgi`` module provides an extended ``Microdot`` class that implements the
WSGI protocol and can be used with a compliant WSGI web server such as
`Gunicorn <https://gunicorn.org/>`_ or
`uWSGI <https://uwsgi-docs.readthedocs.io/en/latest/>`_.
To use a WSGI web server, the application must import the
:class:`Microdot <microdot.wsgi.Microdot>` class from the ``wsgi`` module::
from microdot.wsgi import Microdot
app = Microdot()
@app.route('/')
def index(req):
return 'Hello, World!'
The ``app`` application instance created from this class can be used as a WSGI
callbable with any complaint WSGI web server. If the above application
was stored in a file called *test.py*, then the following command runs the
web application using the Gunicorn web server::
gunicorn test:app
When using the WSGI support, the ``environ`` dictionary provided by the web
server is available to request handlers as ``request.environ``.
.. note::
In spite of WSGI being a synchronous protocol, the Microdot application
internally runs under an asyncio event loop. For that reason, the
recommendation to prefer ``async def`` handlers over ``def`` still applies
under WSGI. Consult the :ref:`Concurrency` section for a discussion of how
the two types of functions are handled by Microdot.

112
docs/extensions/auth.rst Normal file
View File

@@ -0,0 +1,112 @@
Authentication
~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `auth.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/auth.py>`_
* - Required external dependencies
- | None
* - Examples
- | `basic_auth.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/auth/basic_auth.py>`_
| `token_auth.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/auth/token_auth.py>`_
The authentication extension provides helper classes for two commonly used
authentication patterns, described below.
Basic Authentication
^^^^^^^^^^^^^^^^^^^^
`Basic Authentication <https://en.wikipedia.org/wiki/Basic_access_authentication>`_
is a method of authentication that is part of the HTTP specification. It allows
clients to authenticate to a server using a username and a password. Web
browsers have native support for Basic Authentication and will automatically
prompt the user for a username and a password when a protected resource is
accessed.
To use Basic Authentication, create an instance of the :class:`BasicAuth <microdot.auth.BasicAuth>`
class::
from microdot.auth import BasicAuth
auth = BasicAuth(app)
Next, create an authentication function. The function must accept a request
object and a username and password pair provided by the user. If the
credentials are valid, the function must return an object that represents the
user. If the authentication function cannot validate the user provided
credentials it must return ``None``. Decorate the function with
``@auth.authenticate``::
@auth.authenticate
async def verify_user(request, username, password):
user = await load_user_from_database(username)
if user and user.verify_password(password):
return user
To protect a route with authentication, add the ``auth`` instance as a
decorator::
@app.route('/')
@auth
async def index(request):
return f'Hello, {request.g.current_user}!'
While running an authenticated request, the user object returned by the
authenticaction function is accessible as ``request.g.current_user``.
If an endpoint is intended to work with or without authentication, then it can
be protected with the ``auth.optional`` decorator::
@app.route('/')
@auth.optional
async def index(request):
if request.g.current_user:
return f'Hello, {request.g.current_user}!'
else:
return 'Hello, anonymous user!'
As shown in the example, a route can check ``request.g.current_user`` to
determine if the user is authenticated or not.
Token Authentication
^^^^^^^^^^^^^^^^^^^^
To set up token authentication, create an instance of
:class:`TokenAuth <microdot.auth.TokenAuth>`::
from microdot.auth import TokenAuth
auth = TokenAuth()
Then add a function that verifies the token and returns the user it belongs to,
or ``None`` if the token is invalid or expired::
@auth.authenticate
async def verify_token(request, token):
return load_user_from_token(token)
As with Basic authentication, the ``auth`` instance is used as a decorator to
protect your routes, and the authenticated user is accessible from the request
object as ``request.g.current_user``::
@app.route('/')
@auth
async def index(request):
return f'Hello, {request.g.current_user}!'
Optional authentication can also be used with tokens::
@app.route('/')
@auth.optional
async def index(request):
if request.g.current_user:
return f'Hello, {request.g.current_user}!'
else:
return 'Hello, anonymous user!'

34
docs/extensions/cors.rst Normal file
View File

@@ -0,0 +1,34 @@
Cross-Origin Resource Sharing (CORS)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `cors.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/cors.py>`_
* - Required external dependencies
- | None
* - Examples
- | `app.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/cors/app.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)

94
docs/extensions/csrf.rst Normal file
View File

@@ -0,0 +1,94 @@
Cross-Site Request Forgery (CSRF) Protection
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `csrf.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/csrf.py>`_
* - Required external dependencies
- | None
* - Examples
- | `app.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/csrf/app.py>`_
The CSRF extension provides protection against `Cross-Site Request Forgery
(CSRF) <https://owasp.org/www-community/attacks/csrf>`_ attacks. This
protection defends against attackers attempting to submit forms or other
state-changing requests from their own site on behalf of unsuspecting victims,
while taking advantage of the victims previously established sessions or
cookies to impersonate them.
This extension checks the ``Sec-Fetch-Site`` header sent by all modern web
browsers to achieve this protection. As a fallback mechanism for older browsers
that do not support this header, this extension can be linked to the CORS
extension to validate the ``Origin`` header. If you are interested in the
details of this protection mechanism, it is described in the
`OWASP CSRF Prevention Cheat Sheet <https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#fetch-metadata-headers>`_
page.
.. note::
As of December 2025, OWASP considers the use of Fetch Metadata Headers for
CSRF protection a
`defense in depth <https://en.wikipedia.org/wiki/Defence_in_depth>`_
technique that is insufficient on its own.
There is an interesting
`discussion <https://github.com/OWASP/CheatSheetSeries/issues/1803>`_ on
this topic in the OWASP GitHub repository where it appears to be agreement
that this technique provides complete protection for the vast majority of
use cases. If you are unsure if this method works for your use case, please
read this discussion to have more context and make the right decision.
To enable CSRF protection, create an instance of the
:class:`CSRF <microdot.csrf.CSRF>` class and configure the desired options.
Example::
from microdot import Microdot
from microdot.cors import CORS
from microdot.csrf import CSRF
app = Microdot()
cors = CORS(app, allowed_origins=['https://example.com'])
csrf = CSRF(app, cors)
This will protect all routes that use a state-changing method (``POST``,
``PUT``, ``PATCH`` or ``DELETE``) and will return a 403 status code response to
any requests that fail the CSRF check.
If there are routes that need to be exempted from the CSRF check, they can be
decorated with the :meth:`csrf.exempt <microdot.csrf.CSRF.exempt>` decorator::
@app.post('/webhook')
@csrf.exempt
async def webhook(request):
# ...
For some applications it may be more convenient to have CSRF checks turned off
by default, and only apply them to explicitly selected routes. In this case,
pass ``protect_all=False`` when you construct the ``CSRF`` instance and use the
:meth:`csrf.protect <microdot.csrf.CSRF.protect>` decorator::
csrf = CSRF(app, cors, protect_all=False)
@app.post('/submit-form')
@csrf.protect
async def submit_form(request):
# ...
By default, requests coming from different subdomains are considered to be
cross-site, and as such they will not pass the CSRF check. If you'd like
subdomain requests to be considered safe, then set the
``allow_subdomains=True`` option when you create the ``CSRF`` class.
.. note::
This extension is designed to block requests issued by web browsers when
they are found to be unsafe or unauthorized by the application owner. The
method used to determine if a request should be allowed or not is based on
the value of headers that are only sent by web browsers. Clients other than
web browsers are not affected by this extension and can send requests
freely.

21
docs/extensions/index.rst Normal file
View File

@@ -0,0 +1,21 @@
Core Extensions
---------------
Microdot is a highly extensible web application framework. The extensions
described in this section are maintained as part of the Microdot project in
the same source code repository.
.. toctree::
:maxdepth: 1
multipart
websocket
sse
templates
sessions
auth
login
cors
csrf
test_client
production

85
docs/extensions/login.rst Normal file
View File

@@ -0,0 +1,85 @@
User Logins
~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `login.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/auth.py>`_
| `session.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/session.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | CPython: `PyJWT <https://pyjwt.readthedocs.io/>`_
| MicroPython: `jwt.py <https://github.com/micropython/micropython-lib/blob/master/python-ecosys/pyjwt/jwt.py>`_,
`hmac.py <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/hmac/hmac.py>`_
* - Examples
- | `login.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/login/login.py>`_
The login extension provides user login functionality. The logged in state of
the user is stored in the user session cookie, and an optional "remember me"
cookie can also be added to keep the user logged in across browser sessions.
To use this extension, create instances of the
:class:`Session <microdot.session.Session>` and :class:`Login <microdot.login.Login>`
class::
Session(app, secret_key='top-secret!')
login = Login()
The ``Login`` class accept an optional argument with the URL of the login page.
The default for this URL is */login*.
The application must represent users as objects with an ``id`` attribute. A
function decorated with ``@login.user_loader`` is used to load a user object::
@login.user_loader
async def get_user(user_id):
return database.get_user(user_id)
The application must implement the login form. At the point in which the user
credentials have been received and verified, a call to the
:func:`login_user() <microdot.login.Login.login_user>` function must be made to
record the user in the user session::
@app.route('/login', methods=['GET', 'POST'])
async def login(request):
# ...
if user.check_password(password):
return await login.login_user(request, user, remember=remember_me)
return redirect('/login')
The optional ``remember`` argument is used to add a remember me cookie that
will log the user in automatically in future sessions. A value of ``True`` will
keep the log in active for 30 days. Alternatively, an integer number of days
can be passed in this argument.
Any routes that require the user to be logged in must be decorated with
:func:`@login <microdot.login.Login.__call__>`::
@app.route('/')
@login
async def index(request):
# ...
Routes that are of a sensitive nature can be decorated with
:func:`@login.fresh <microdot.login.Login.fresh>`
instead. This decorator requires that the user has logged in during the current
session, and will ask the user to logged in again if the session was
authenticated through a remember me cookie::
@app.get('/fresh')
@login.fresh
async def fresh(request):
# ...
To log out a user, the :func:`logout_user() <microdot.auth.Login.logout_user>`
is used::
@app.post('/logout')
@login
async def logout(request):
await login.logout_user(request)
return redirect('/')

View File

@@ -0,0 +1,73 @@
Multipart Forms
~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `multipart.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/multipart.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | None
* - Examples
- | `formdata.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/uploads/formdata.py>`_
The multipart extension handles multipart forms, including those that have file
uploads.
The :func:`with_form_data <microdot.multipart.with_form_data>` decorator
provides the simplest way to work with these forms. With this decorator added
to the route, whenever the client sends a multipart request the
:attr:`request.form <microdot.Request.form>` and
:attr:`request.files <microdot.Request.files>` properties are populated with
the submitted data. For form fields the field values are always strings. For
files, they are instances of the
:class:`FileUpload <microdot.multipart.FileUpload>` class.
Example::
from microdot.multipart import with_form_data
@app.post('/upload')
@with_form_data
async def upload(request):
print('form fields:', request.form)
print('files:', request.files)
One disadvantage of the ``@with_form_data`` decorator is that it has to copy
any uploaded files to memory or temporary disk files, depending on their size.
The :attr:`FileUpload.max_memory_size <microdot.multipart.FileUpload.max_memory_size>`
attribute can be used to control the cutoff size above which a file upload
is transferred to a temporary file.
A more performant alternative to the ``@with_form_data`` decorator is the
:class:`FormDataIter <microdot.multipart.FormDataIter>` class, which iterates
over the form fields sequentially, giving the application the option to parse
the form fields on the fly and decide what to copy and what to discard. When
using ``FormDataIter`` the ``request.form`` and ``request.files`` attributes
are not used.
Example::
from microdot.multipart import FormDataIter
@app.post('/upload')
async def upload(request):
async for name, value in FormDataIter(request):
print(name, value)
For fields that contain an uploaded file, the ``value`` returned by the
iterator is the same ``FileUpload`` instance. The application can choose to
save the file with the :meth:`save() <microdot.multipart.FileUpload.save>`
method, or read it with the :meth:`read() <microdot.multipart.FileUpload.read>`
method, optionally passing a size to read it in chunks. The
:meth:`copy() <microdot.multipart.FileUpload.copy>` method is also available to
apply the copying logic used by the ``@with_form_data`` decorator, which is
inefficient but allows the file to be set aside to be processed later, after
the remaining form fields.

View File

@@ -0,0 +1,120 @@
Production Deployments
~~~~~~~~~~~~~~~~~~~~~~
The ``Microdot`` class creates its own simple web server. This is enough for an
application deployed with MicroPython, but when using CPython it may be useful
to use a separate, battle-tested web server. To address this need, Microdot
provides extensions that implement the ASGI and WSGI protocols.
Using an ASGI Web Server
^^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython only
* - Required Microdot source files
- | `asgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/asgi.py>`_
* - Required external dependencies
- | An ASGI web server, such as `Uvicorn <https://www.uvicorn.org/>`_.
* - Examples
- | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_asgi.py>`_
| `hello_asgi.py (uTemplate) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello_asgi.py>`_
| `hello_asgi.py (Jinja) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello_asgi.py>`_
| `echo_asgi.py (WebSocket) <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_asgi.py>`_
The ``asgi`` module provides an extended ``Microdot`` class that
implements the ASGI protocol and can be used with a compliant ASGI server such
as `Uvicorn <https://www.uvicorn.org/>`_.
To use an ASGI web server, the application must import the
:class:`Microdot <microdot.asgi.Microdot>` class from the ``asgi`` module::
from microdot.asgi import Microdot
app = Microdot()
@app.route('/')
async def index(req):
return 'Hello, World!'
The ``app`` application instance created from this class can be used as the
ASGI callable with any complaint ASGI web server. If the above example
application was stored in a file called *test.py*, then the following command
runs the web application using the Uvicorn web server::
uvicorn test:app
When using the ASGI support, the ``scope`` dictionary provided by the web
server is available to request handlers as ``request.asgi_scope``.
The application instance can be initialized with ``lifespan_startup`` and
``lifespan_shutdown`` arguments, which are invoked when the web server sends
the ASGI lifespan signals with the ASGI scope as only argument::
async def startup(scope):
pass
async def shutdown(scope):
pass
app = Microdot(lifespan_startup=startup, lifespan_shutdown=shutdown)
Using a WSGI Web Server
^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython only
* - Required Microdot source files
- | `wsgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/wsgi.py>`_
* - Required external dependencies
- | A WSGI web server, such as `Gunicorn <https://gunicorn.org/>`_.
* - Examples
- | `hello_wsgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_wsgi.py>`_
| `hello_wsgi.py (uTemplate) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello_wsgi.py>`_
| `hello_wsgi.py (Jinja) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello_wsgi.py>`_
| `echo_wsgi.py (WebSocket) <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_wsgi.py>`_
The ``wsgi`` module provides an extended ``Microdot`` class that implements the
WSGI protocol and can be used with a compliant WSGI web server such as
`Gunicorn <https://gunicorn.org/>`_ or
`uWSGI <https://uwsgi-docs.readthedocs.io/en/latest/>`_.
To use a WSGI web server, the application must import the
:class:`Microdot <microdot.wsgi.Microdot>` class from the ``wsgi`` module::
from microdot.wsgi import Microdot
app = Microdot()
@app.route('/')
def index(req):
return 'Hello, World!'
The ``app`` application instance created from this class can be used as a WSGI
callbable with any complaint WSGI web server. If the above application
was stored in a file called *test.py*, then the following command runs the
web application using the Gunicorn web server::
gunicorn test:app
When using the WSGI support, the ``environ`` dictionary provided by the web
server is available to request handlers as ``request.environ``.
.. note::
In spite of WSGI being a synchronous protocol, the Microdot application
internally runs under an asyncio event loop. For that reason, the
recommendation to prefer ``async def`` handlers over ``def`` still applies
under WSGI. Consult the :ref:`Concurrency` section for a discussion of how
the two types of functions are handled by Microdot.

View File

@@ -0,0 +1,68 @@
Secure User Sessions
~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `session.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/session.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | CPython: `PyJWT <https://pyjwt.readthedocs.io/>`_
| MicroPython: `jwt.py <https://github.com/micropython/micropython-lib/blob/master/python-ecosys/pyjwt/jwt.py>`_,
`hmac.py <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/hmac/hmac.py>`_
* - Examples
- | `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
user sessions. The session data is stored as a signed cookie in the client's
browser, in `JSON Web Token (JWT) <https://en.wikipedia.org/wiki/JSON_Web_Token>`_
format.
To work with user sessions, the application first must configure a secret key
that will be used to sign the session cookies. It is very important that this
key is kept secret, as its name implies. An attacker who is in possession of
this key can generate valid user session cookies with any contents.
To initialize the session extension and configure the secret key, create a
:class:`Session <microdot.session.Session>` object::
Session(app, secret_key='top-secret')
The :func:`with_session <microdot.session.with_session>` decorator is the
most convenient way to retrieve the session at the start of a request::
from microdot import Microdot, redirect
from microdot.session import Session, with_session
app = Microdot()
Session(app, secret_key='top-secret')
@app.route('/', methods=['GET', 'POST'])
@with_session
async def index(req, session):
username = session.get('username')
if req.method == 'POST':
username = req.form.get('username')
session['username'] = username
session.save()
return redirect('/')
if username is None:
return 'Not logged in'
else:
return 'Logged in as ' + username
@app.post('/logout')
@with_session
async def logout(req, session):
session.delete()
return redirect('/')
The :func:`save() <microdot.session.SessionDict.save>` and
:func:`delete() <microdot.session.SessionDict.delete>` methods are used to update
and destroy the user session respectively.

60
docs/extensions/sse.rst Normal file
View File

@@ -0,0 +1,60 @@
Server-Sent Events
~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `sse.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/sse.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | None
* - Examples
- | `counter.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/sse/counter.py>`_
The Server-Sent Events (SSE) extension simplifies the creation of a streaming
endpoint that follows the SSE web standard. The :func:`with_sse <microdot.sse.with_sse>`
decorator is used to mark a route as an SSE handler. Decorated routes receive
an SSE object as second argument. The SSE object provides a ``send()``
asynchronous method to send an event to the client.
Example::
from microdot.sse import with_sse
@app.route('/events')
@with_sse
async def events(request, sse):
for i in range(10):
await asyncio.sleep(1)
await sse.send({'counter': i}) # unnamed event
await sse.send('end', event='comment') # named event
To end the SSE connection, the route handler can exit, without returning
anything, as shown in the above examples.
If the client ends the SSE connection from their side, the route function is
cancelled. The route function can catch the ``CancelledError`` exception from
asyncio to perform cleanup tasks::
@app.route('/events')
@with_sse
async def events(request, sse):
try:
i = 0
while True:
await asyncio.sleep(1)
await sse.send({'counter': i})
i += 1
except asyncio.CancelledError:
print('Client disconnected!')
.. note::
The SSE protocol is unidirectional, so there is no ``receive()`` method in
the SSE object. For bidirectional communication with the client, use the
WebSocket extension.

View File

@@ -0,0 +1,123 @@
Templates
~~~~~~~~~
Many web applications use HTML templates for rendering content to clients.
Microdot includes extensions to render templates with the
`utemplate <https://github.com/pfalcon/utemplate>`_ package on CPython and
MicroPython, and with `Jinja <https://jinja.palletsprojects.com/>`_ only on
CPython.
Using the uTemplate Engine
^^^^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `utemplate.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/utemplate.py>`_
* - Required external dependencies
- | `utemplate <https://github.com/pfalcon/utemplate/tree/master/utemplate>`_
* - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello.py>`_
The :class:`Template <microdot.utemplate.Template>` class is used to load a
template. The argument is the template filename, relative to the templates
directory, which is *templates* by default.
The ``Template`` object has a :func:`render() <microdot.utemplate.Template.render>`
method that renders the template to a string. This method receives any
arguments that are used by the template.
Example::
from microdot.utemplate import Template
@app.get('/')
async def index(req):
return Template('index.html').render()
The ``Template`` object also has a :func:`generate() <microdot.utemplate.Template.generate>`
method, which returns a generator instead of a string. The
:func:`render_async() <microdot.utemplate.Template.render_async>` and
:func:`generate_async() <microdot.utemplate.Template.generate_async>` methods
are the asynchronous versions of these two methods.
The default location from where templates are loaded is the *templates*
subdirectory. This location can be changed with the
:func:`Template.initialize <microdot.utemplate.Template.initialize>` class
method::
Template.initialize('my_templates')
By default templates are automatically compiled the first time they are
rendered, or when their last modified timestamp is more recent than the
compiledo file's timestamp. This loading behavior can be changed by switching
to a different template loader. For example, if the templates are pre-compiled,
the timestamp check and compile steps can be removed by switching to the
"compiled" template loader::
from utemplate import compiled
from microdot.utemplate import Template
Template.initialize(loader_class=compiled.Loader)
Consult the `uTemplate documentation <https://github.com/pfalcon/utemplate>`_
for additional information regarding template loaders.
Using the Jinja Engine
^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython only
* - Required Microdot source files
- | `jinja.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/jinja.py>`_
* - Required external dependencies
- | `Jinja2 <https://jinja.palletsprojects.com/>`_
* - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello.py>`_
The :class:`Template <microdot.jinja.Template>` class is used to load a
template. The argument is the template filename, relative to the templates
directory, which is *templates* by default.
The ``Template`` object has a :func:`render() <microdot.jinja.Template.render>`
method that renders the template to a string. This method receives any
arguments that are used by the template.
Example::
from microdot.jinja import Template
@app.get('/')
async def index(req):
return Template('index.html').render()
The ``Template`` object also has a :func:`generate() <microdot.jinja.Template.generate>`
method, which returns a generator instead of a string.
The default location from where templates are loaded is the *templates*
subdirectory. This location can be changed with the
:func:`Template.initialize <microdot.jinja.Template.initialize>` class method::
Template.initialize('my_templates')
The ``initialize()`` method also accepts ``enable_async`` argument, which
can be set to ``True`` if asynchronous rendering of templates is desired. If
this option is enabled, then the
:func:`render_async() <microdot.jinja.Template.render_async>` and
:func:`generate_async() <microdot.jinja.Template.generate_async>` methods
must be used.
.. note::
The Jinja extension is not compatible with MicroPython.

View File

@@ -0,0 +1,36 @@
Test Client
~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `test_client.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/test_client.py>`_
* - Required external dependencies
- | None
The Microdot Test Client is a utility class that can be used in tests to send
requests into the application without having to start a web server.
Example::
from microdot import Microdot
from microdot.test_client import TestClient
app = Microdot()
@app.route('/')
def index(req):
return 'Hello, World!'
async def test_app():
client = TestClient(app)
response = await client.get('/')
assert response.text == 'Hello, World!'
See the documentation for the :class:`TestClient <microdot.test_client.TestClient>`
class for more details.

View File

@@ -0,0 +1,63 @@
WebSocket
~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `websocket.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/websocket.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | None
* - Examples
- | `echo.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo.py>`_
The WebSocket extension gives the application the ability to handle WebSocket
requests. The :func:`with_websocket <microdot.websocket.with_websocket>`
decorator is used to mark a route handler as a WebSocket handler. Decorated
routes receive a WebSocket object as a second argument. The WebSocket object
provides ``send()`` and ``receive()`` asynchronous methods to send and receive
messages respectively.
Example::
from microdot.websocket import with_websocket
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
To end the WebSocket connection, the route handler can exit, without returning
anything::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
if message == 'exit':
break
await ws.send(message)
await ws.send('goodbye')
If the client ends the WebSocket connection from their side, the route function
is cancelled. The route function can catch the ``CancelledError`` exception
from asyncio to perform cleanup tasks::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
try:
while True:
message = await ws.receive()
await ws.send(message)
except asyncio.CancelledError:
print('Client disconnected!')

View File

@@ -1,5 +1,8 @@
Cross-Compiling and Freezing Microdot (MicroPython Only)
--------------------------------------------------------
Cross-Compiling and Freezing Microdot
-------------------------------------
.. note::
This section only applies when using Microdot on MicroPython.
Microdot is a fairly small framework, so its size is not something you need to
be concerned about unless you are working with MicroPython on hardware with a
@@ -36,7 +39,7 @@ Cross-Compiling
An issue that is common with low-end microcontroller boards is that they do not
have enough RAM for the MicroPython compiler to compile the source files, but
once the code is compiled they are able to run it without problems.
once the code is compiled they are able to run it just fine.
To address this, MicroPython allows you to cross-compile source files on your
desktop or laptop computer and then upload their compiled versions to the
@@ -82,8 +85,8 @@ imported directly from the device's ROM, leaving more RAM available for
application use.
The process to create a custom firmware is unfortunately non-trivial and
different depending on the device, so you will need to consult the MicroPython
documentation that applies to your device to learn how to do this.
different for each microcontroller platform, so you will need to consult the
MicroPython documentation that applies to your device to learn how to do this.
The part of the process that is common to all devices is the creation of a
`manifest file <https://docs.micropython.org/en/latest/reference/manifest.html>`_

View File

@@ -0,0 +1,11 @@
Implementation notes
--------------------
This section covers some implementation aspects of Microdot.
.. toctree::
:maxdepth: 1
migrating
freezing

View File

@@ -14,13 +14,14 @@ systems with limited resources such as microcontrollers. Both standard Python
(CPython) and `MicroPython <https://micropython.org>`_ are supported.
.. toctree::
:maxdepth: 3
:maxdepth: 2
intro
extensions
migrating
freezing
api
users-guide/index
extensions/index
implementation/index
api/index
contributing
* :ref:`genindex`
* :ref:`search`

View File

@@ -1,13 +1,14 @@
Installation
------------
The installation method is different depending on the version of Python.
The installation method is different depending on which flavor of Python you
are using.
CPython Installation
~~~~~~~~~~~~~~~~~~~~
For use with standard Python (CPython) projects, Microdot and all of its core
extensions are installed with ``pip``::
extensions are installed with ``pip`` or any of its alternatives::
pip install microdot
@@ -17,882 +18,27 @@ MicroPython Installation
For MicroPython, the recommended approach is to manually copy the necessary
source files from the
`GitHub repository <https://github.com/miguelgrinberg/microdot/tree/main/src>`_
into your device, ideally after
`compiling <https://docs.micropython.org/en/latest/reference/mpyfiles.html>`_
them to *.mpy* files. These source files can also be
`frozen <https://docs.micropython.org/en/latest/develop/optimizations.html?highlight=frozen#frozen-bytecode>`_
and incorporated into a custom MicroPython firmware.
into your device.
Use the following guidelines to know what files to copy:
* For a minimal setup with only the base web server functionality, copy
`microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
into your project.
* For a configuration that includes one or more optional extensions, create a
*microdot* directory in your device and copy the following files:
to your device.
* For a configuration that includes one or more of the optional extensions,
create a *microdot* directory in your device and copy the following files:
* `__init__.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/__init__.py>`_
* `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
* any needed `extensions <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot>`_.
Some of the low end devices are perfectly capable of running Microdot once
compiled, but do not have enough RAM for the compiler. For these cases you can
`pre-compile <https://docs.micropython.org/en/latest/reference/mpyfiles.html>`_
the files to *.mpy* files for the version of MicroPython that you use in your
device.
Getting Started
---------------
If space in your device is extremely tight, you may also consider
`freezing <https://docs.micropython.org/en/latest/develop/optimizations.html?highlight=frozen#frozen-bytecode>`_
the Microdot files and incorporating them into a custom MicroPython firmware.
This section describes the main features of Microdot in an informal manner.
For detailed reference information, consult the :ref:`API Reference`.
If you are familiar with releases of Microdot before 2.x, review the
:ref:`Migration Guide <Migrating to Microdot 2.x from Older Releases>`.
A Simple Microdot Web Server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The following is an example of a simple web server::
from microdot import Microdot
app = Microdot()
@app.route('/')
async def index(request):
return 'Hello, world!'
app.run()
The script imports the :class:`Microdot <microdot.Microdot>` class and creates
an application instance from it.
The application instance provides a :func:`route() <microdot.Microdot.route>`
decorator, which is used to define one or more routes, as needed by the
application.
The ``route()`` decorator takes the path portion of the URL as an
argument, and maps it to the decorated function, so that the function is called
when the client requests the URL.
When the function is called, it is passed a :class:`Request <microdot.Request>`
object as an argument, which provides access to the information passed by the
client. The value returned by the function is sent back to the client as the
response.
Microdot is an asynchronous framework that uses the ``asyncio`` package. Route
handler functions can be defined as ``async def`` or ``def`` functions, but
``async def`` functions are recommended for performance.
The :func:`run() <microdot.Microdot.run>` method starts the application's web
server on port 5000 by default, and creates its own asynchronous loop. This
method blocks while it waits for connections from clients.
For some applications it may be necessary to run the web server alongside other
asynchronous tasks, on an already running loop. In that case, instead of
``app.run()`` the web server can be started by invoking the
:func:`start_server() <microdot.Microdot.start_server>` coroutine as shown in
the following example::
import asyncio
from microdot import Microdot
app = Microdot()
@app.route('/')
async def index(request):
return 'Hello, world!'
async def main():
# start the server in a background task
server = asyncio.create_task(app.start_server())
# ... do other asynchronous work here ...
# cleanup before ending the application
await server
asyncio.run(main())
Running with CPython
^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
* - Required external dependencies
- | None
* - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello.py>`_
When using CPython, you can start the web server by running the script that
has the ``app.run()`` call at the bottom::
python main.py
After starting the script, open a web browser and navigate to
*http://localhost:5000/* to access the application at the default address for
the Microdot web server. From other computers in the same network, use the IP
address or hostname of the computer running the script instead of
``localhost``.
Running with MicroPython
^^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
* - Required external dependencies
- | None
* - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello.py>`_
| `gpio.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/gpio/gpio.py>`_
When using MicroPython, you can upload a *main.py* file containing the web
server code to your device, along with the required Microdot files, as defined
in the :ref:`MicroPython Installation` section.
MicroPython will automatically run *main.py* when the device is powered on, so
the web server will automatically start. The application can be accessed on
port 5000 at the device's IP address. As indicated above, the port can be
changed by passing the ``port`` argument to the ``run()`` method.
.. note::
Microdot does not configure the network interface of the device in which it
is running. If your device requires a network connection to be made in
advance, for example to a Wi-Fi access point, this must be configured before
the ``run()`` method is invoked.
Web Server Configuration
^^^^^^^^^^^^^^^^^^^^^^^^
The :func:`run() <microdot.Microdot.run>` and
:func:`start_server() <microdot.Microdot.start_server>` methods support a few
arguments to configure the web server.
- ``port``: The port number to listen on. Pass the desired port number in this
argument to use a port different than the default of 5000. For example::
app.run(port=6000)
- ``host``: The IP address of the network interface to listen on. By default
the server listens on all available interfaces. To listen only on the local
loopback interface, pass ``'127.0.0.1'`` as value for this argument.
- ``debug``: when set to ``True``, the server ouputs logging information to the
console. The default is ``False``.
- ``ssl``: an ``SSLContext`` instance that configures the server to use TLS
encryption, or ``None`` to disable TLS use. The default is ``None``. The
following example demonstrates how to configure the server with an SSL
certificate stored in *cert.pem* and *key.pem* files::
import ssl
# ...
sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sslctx.load_cert_chain('cert.pem', 'key.pem')
app.run(port=4443, debug=True, ssl=sslctx)
.. note::
When using CPython, the certificate and key files must be given in PEM
format. When using MicroPython, these files must be given in DER format.
Defining Routes
~~~~~~~~~~~~~~~
The :func:`route() <microdot.Microdot.route>` decorator is used to associate an
application URL with the function that handles it. The only required argument
to the decorator is the path portion of the URL.
The following example creates a route for the root URL of the application::
@app.route('/')
async def index(request):
return 'Hello, world!'
When a client requests the root URL (for example, *http://localhost:5000/*),
Microdot will call the ``index()`` function, passing it a
:class:`Request <microdot.Request>` object. The return value of the function
is the response that is sent to the client.
Below is another example, this one with a route for a URL with two components
in its path::
@app.route('/users/active')
async def active_users(request):
return 'Active users: Susan, Joe, and Bob'
The complete URL that maps to this route is
*http://localhost:5000/users/active*.
An application can include multiple routes. Microdot uses the path portion of
the URL to determine the correct route function to call for each incoming
request.
Choosing the HTTP Method
^^^^^^^^^^^^^^^^^^^^^^^^
All the example routes shown above are associated with ``GET`` requests, which
are the default. Applications often need to define routes for other HTTP
methods, such as ``POST``, ``PUT``, ``PATCH`` and ``DELETE``. The ``route()``
decorator takes a ``methods`` optional argument, in which the application can
provide a list of HTTP methods that the route should be associated with on the
given path.
The following example defines a route that handles ``GET`` and ``POST``
requests within the same function::
@app.route('/invoices', methods=['GET', 'POST'])
async def invoices(request):
if request.method == 'GET':
return 'get invoices'
elif request.method == 'POST':
return 'create an invoice'
As an alternative to the example above, in which a single function is used to
handle multiple HTTP methods, sometimes it may be desirable to write a separate
function for each HTTP method. The above example can be implemented with two
routes as follows::
@app.route('/invoices', methods=['GET'])
async def get_invoices(request):
return 'get invoices'
@app.route('/invoices', methods=['POST'])
async def create_invoice(request):
return 'create an invoice'
Microdot provides the :func:`get() <microdot.Microdot.get>`,
:func:`post() <microdot.Microdot.post>`, :func:`put() <microdot.Microdot.put>`,
:func:`patch() <microdot.Microdot.patch>`, and
:func:`delete() <microdot.Microdot.delete>` decorators as shortcuts for the
corresponding HTTP methods. The two example routes above can be written more
concisely with them::
@app.get('/invoices')
async def get_invoices(request):
return 'get invoices'
@app.post('/invoices')
async def create_invoice(request):
return 'create an invoice'
Including Dynamic Components in the URL Path
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The examples shown above all use hardcoded URL paths. Microdot also supports
the definition of routes that have dynamic components in the path. For example,
the following route associates all URLs that have a path following the pattern
*http://localhost:5000/users/<username>* with the ``get_user()`` function::
@app.get('/users/<username>')
async def get_user(request, username):
return 'User: ' + username
As shown in the example, a path component that is enclosed in angle brackets
is considered a placeholder. Microdot accepts any values for that portion of
the URL path, and passes the value received to the function as an argument
after the request object.
Routes are not limited to a single dynamic component. The following route shows
how multiple dynamic components can be included in the path::
@app.get('/users/<firstname>/<lastname>')
async def get_user(request, firstname, lastname):
return 'User: ' + firstname + ' ' + lastname
Dynamic path components are considered to be strings by default. An explicit
type can be specified as a prefix, separated from the dynamic component name by
a colon. The following route has two dynamic components declared as an integer
and a string respectively::
@app.get('/users/<int:id>/<string:username>')
async def get_user(request, id, username):
return 'User: ' + username + ' (' + str(id) + ')'
If a dynamic path component is defined as an integer, the value passed to the
route function is also an integer. If the client sends a value that is not an
integer in the corresponding section of the URL path, then the URL will not
match and the route will not be called.
A special type ``path`` can be used to capture the remainder of the path as a
single argument. The difference between an argument of type ``path`` and one of
type ``string`` is that the latter stops capturing when a ``/`` appears in the
URL::
@app.get('/tests/<path:path>')
async def get_test(request, path):
return 'Test: ' + path
For the most control, the ``re`` type allows the application to provide a
custom regular expression for the dynamic component. The next example defines
a route that only matches usernames that begin with an upper or lower case
letter, followed by a sequence of letters or numbers::
@app.get('/users/<re:[a-zA-Z][a-zA-Z0-9]*:username>')
async def get_user(request, username):
return 'User: ' + username
.. note::
Dynamic path components are passed to route functions as keyword arguments,
so the names of the function arguments must match the names declared in the
path specification.
Before and After Request Handlers
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
It is common for applications to need to perform one or more actions before a
request is handled. Examples include authenticating and/or authorizing the
client, opening a connection to a database, or checking if the requested
resource can be obtained from a cache. The
:func:`before_request() <microdot.Microdot.before_request>` decorator registers
a function to be called before the request is dispatched to the route function.
The following example registers a before-request handler that ensures that the
client is authenticated before the request is handled::
@app.before_request
async def authenticate(request):
user = authorize(request)
if not user:
return 'Unauthorized', 401
request.g.user = user
Before-request handlers receive the request object as an argument. If the
function returns a value, Microdot sends it to the client as the response, and
does not invoke the route function. This gives before-request handlers the
power to intercept a request if necessary. The example above uses this
technique to prevent an unauthorized user from accessing the requested
route.
After-request handlers registered with the
:func:`after_request() <microdot.Microdot.after_request>` decorator are called
after the route function returns a response. Their purpose is to perform any
common closing or cleanup tasks. The next example shows a combination of
before- and after-request handlers that print the time it takes for a request
to be handled::
@app.before_request
async def start_timer(request):
request.g.start_time = time.time()
@app.after_request
async def end_timer(request, response):
duration = time.time() - request.g.start_time
print(f'Request took {duration:0.2f} seconds')
After-request handlers receive the request and response objects as arguments,
and they can return a modified response object to replace the original. If
no value is returned from an after-request handler, then the original response
object is 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 after performing any necessary
cleanup.
.. note::
The :ref:`request.g <The "g" Object>` object used in many of the above
examples is a special object that allows the before- and after-request
handlers, as well as the route function to share data during the life of the
request.
Error Handlers
^^^^^^^^^^^^^^
When an error occurs during the handling of a request, Microdot ensures that
the client receives an appropriate error response. Some of the common errors
automatically handled by Microdot are:
- 400 for malformed requests.
- 404 for URLs that are unknown.
- 405 for URLs that are known, but not implemented for the requested HTTP
method.
- 413 for requests that are larger than the allowed size.
- 500 when the application raises an unhandled exception.
While the above errors are fully complaint with the HTTP specification, the
application might want to provide custom responses for them. The
:func:`errorhandler() <microdot.Microdot.errorhandler>` decorator registers
functions to respond to specific error codes. The following example shows a
custom error handler for 404 errors::
@app.errorhandler(404)
async def not_found(request):
return {'error': 'resource not found'}, 404
The ``errorhandler()`` decorator has a second form, in which it takes an
exception class as an argument. Microdot will invoke the handler when an
unhandled exception that is an instance of the given class is raised. The next
example provides a custom response for division by zero errors::
@app.errorhandler(ZeroDivisionError)
async def division_by_zero(request, exception):
return {'error': 'division by zero'}, 500
When the raised exception class does not have an error handler defined, but
one or more of its parent classes do, Microdot makes an attempt to invoke the
most specific handler.
Mounting a Sub-Application
^^^^^^^^^^^^^^^^^^^^^^^^^^
Small Microdot applications can be written as a single source file, but this
is not the best option for applications that past a certain size. To make it
simpler to write large applications, Microdot supports the concept of
sub-applications that can be "mounted" on a larger application, possibly with
a common URL prefix applied to all of its routes. For developers familiar with
the Flask framework, this is a similar concept to Flask's blueprints.
Consider, for example, a *customers.py* sub-application that implements
operations on customers::
from microdot import Microdot
customers_app = Microdot()
@customers_app.get('/')
async def get_customers(request):
# return all customers
@customers_app.post('/')
async def new_customer(request):
# create a new customer
Similar to the above, the *orders.py* sub-application implements operations on
customer orders::
from microdot import Microdot
orders_app = Microdot()
@orders_app.get('/')
async def get_orders(request):
# return all orders
@orders_app.post('/')
async def new_order(request):
# create a new order
Now the main application, which is stored in *main.py*, can import and mount
the sub-applications to build the larger combined application::
from microdot import Microdot
from customers import customers_app
from orders import orders_app
def create_app():
app = Microdot()
app.mount(customers_app, url_prefix='/customers')
app.mount(orders_app, url_prefix='/orders')
return app
app = create_app()
app.run()
The resulting application will have the customer endpoints available at
*/customers/* and the order endpoints available at */orders/*.
.. note::
Before-request, after-request and error handlers defined in the
sub-application are also copied over to the main application at mount time.
Once installed in the main application, these handlers will apply to the
whole application and not just the sub-application in which they were
created.
Shutting Down the Server
^^^^^^^^^^^^^^^^^^^^^^^^
Web servers are designed to run forever, and are often stopped by sending them
an interrupt signal. But having a way to gracefully stop the server is
sometimes useful, especially in testing environments. Microdot provides a
:func:`shutdown() <microdot.Microdot.shutdown>` method that can be invoked
during the handling of a route to gracefully shut down the server when that
request completes. The next example shows how to use this feature::
@app.get('/shutdown')
async def shutdown(request):
request.app.shutdown()
return 'The server is shutting down...'
The request that invokes the ``shutdown()`` method will complete, and then the
server will not accept any new requests and stop once any remaining requests
complete. At this point the ``app.run()`` call will return.
The Request Object
~~~~~~~~~~~~~~~~~~
The :class:`Request <microdot.Request>` object encapsulates all the information
passed by the client. It is passed as an argument to route handlers, as well as
to before-request, after-request and error handlers.
Request Attributes
^^^^^^^^^^^^^^^^^^
The request object provides access to the request attributes, including:
- :attr:`method <microdot.Request.method>`: The HTTP method of the request.
- :attr:`path <microdot.Request.path>`: The path of the request.
- :attr:`args <microdot.Request.args>`: The query string parameters of the
request, as a :class:`MultiDict <microdot.MultiDict>` object.
- :attr:`headers <microdot.Request.headers>`: The headers of the request, as a
dictionary.
- :attr:`cookies <microdot.Request.cookies>`: The cookies that the client sent
with the request, as a dictionary.
- :attr:`content_type <microdot.Request.content_type>`: The content type
specified by the client, or ``None`` if no content type was specified.
- :attr:`content_length <microdot.Request.content_length>`: The content
length of the request, or 0 if no content length was specified.
- :attr:`client_addr <microdot.Request.client_addr>`: The network address of
the client, as a tuple (host, port).
- :attr:`app <microdot.Request.app>`: The application instance that created the
request.
- :attr:`g <microdot.Request.g>`: The ``g`` object, where handlers can store
request-specific data to be shared among handlers. See :ref:`The "g" Object`
for details.
JSON Payloads
^^^^^^^^^^^^^
When the client sends a request that contains JSON data in the body, the
application can access the parsed JSON data using the
:attr:`json <microdot.Request.json>` attribute. The following example shows how
to use this attribute::
@app.post('/customers')
async def create_customer(request):
customer = request.json
# do something with customer
return {'success': True}
.. note::
The client must set the ``Content-Type`` header to ``application/json`` for
the ``json`` attribute of the request object to be populated.
URLEncoded Form Data
^^^^^^^^^^^^^^^^^^^^
The request object also supports standard HTML form submissions through the
:attr:`form <microdot.Request.form>` attribute, which presents the form data
as a :class:`MultiDict <microdot.MultiDict>` object. Example::
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = 'Unknown'
if req.method == 'POST':
name = req.form.get('name')
return f'Hello {name}'
.. note::
Form submissions are only parsed when the ``Content-Type`` header is set by
the client to ``application/x-www-form-urlencoded``. Form submissions using
the ``multipart/form-data`` content type are currently not supported.
Accessing the Raw Request Body
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For cases in which neither JSON nor form data is expected, the
:attr:`body <microdot.Request.body>` request attribute returns the entire body
of the request as a byte sequence.
If the expected body is too large to fit safely in memory, the application can
use the :attr:`stream <microdot.Request.stream>` request attribute to read the
body contents as a file-like object. The
:attr:`max_body_length <microdot.Request.max_body_length>` attribute of the
request object defines the size at which bodies are streamed instead of loaded
into memory.
Cookies
^^^^^^^
Cookies that are sent by the client are made available through the
:attr:`cookies <microdot.Request.cookies>` attribute of the request object in
dictionary form.
The "g" Object
^^^^^^^^^^^^^^
Sometimes applications need to store data during the lifetime of a request, so
that it can be shared between the before- and after-request handlers, the
route function and any error handlers. The request object provides the
:attr:`g <microdot.Request.g>` attribute for that purpose.
In the following example, a before request handler authorizes the client and
stores the username so that the route function can use it::
@app.before_request
async def authorize(request):
username = authenticate_user(request)
if not username:
return 'Unauthorized', 401
request.g.username = username
@app.get('/')
async def index(request):
return f'Hello, {request.g.username}!'
Request-Specific After-Request Handlers
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Sometimes applications need to perform operations on the response object
before it is sent to the client, for example to set or remove a cookie. A good
option to use for this is to define a request-specific after-request handler
using the :func:`after_request <microdot.Microdot.after_request>` decorator.
Request-specific after-request handlers are called by Microdot after the route
function returns and all the application-wide after-request handlers have been
called.
The next example shows how a cookie can be updated using a request-specific
after-request handler defined inside a route function::
@app.post('/logout')
async def logout(request):
@request.after_request
def reset_session(request, response):
response.set_cookie('session', '', http_only=True)
return response
return 'Logged out'
Request Limits
^^^^^^^^^^^^^^
To help prevent malicious attacks, Microdot provides some configuration options
to limit the amount of information that is accepted:
- :attr:`max_content_length <microdot.Request.max_content_length>`: The
maximum size accepted for the request body, in bytes. When a client sends a
request that is larger than this, the server will respond with a 413 error.
The default is 16KB.
- :attr:`max_body_length <microdot.Request.max_body_length>`: The maximum
size that is loaded in the :attr:`body <microdot.Request.body>` attribute, in
bytes. Requests that have a body that is larger than this size but smaller
than the size set for ``max_content_length`` can only be accessed through the
:attr:`stream <microdot.Request.stream>` attribute. The default is also 16KB.
- :attr:`max_readline <microdot.Request.max_readline>`: The maximum allowed
size for a request line, in bytes. The default is 2KB.
The following example configures the application to accept requests with
payloads up to 1MB in size, but prevents requests that are larger than 8KB from
being loaded into memory::
from microdot import Request
Request.max_content_length = 1024 * 1024
Request.max_body_length = 8 * 1024
Responses
~~~~~~~~~
The value or values that are returned from the route function are used by
Microdot to build the response that is sent to the client. The following
sections describe the different types of responses that are supported.
The Three Parts of a Response
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Route functions can return one, two or three values. The first or only value is
always returned to the client in the response body::
@app.get('/')
async def index(request):
return 'Hello, World!'
In the above example, Microdot issues a standard 200 status code response, and
inserts default headers.
The application can provide its own status code as a second value returned from
the route to override the 200 default. The example below returns a 202 status
code::
@app.get('/')
async def index(request):
return 'Hello, World!', 202
The application can also return a third value, a dictionary with additional
headers that are added to, or replace the default ones included by Microdot.
The next example returns an HTML response, instead of a default text response::
@app.get('/')
async def index(request):
return '<h1>Hello, World!</h1>', 202, {'Content-Type': 'text/html'}
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 status
code::
@app.get('/')
async def index(request):
return '<h1>Hello, World!</h1>', {'Content-Type': 'text/html'}
The application can also return a :class:`Response <microdot.Response>` object
containing all the details of the response as a single value.
JSON Responses
^^^^^^^^^^^^^^
If the application needs to return a response with JSON formatted data, it can
return a dictionary or a list as the first value, and Microdot will
automatically format the response as JSON.
Example::
@app.get('/')
async def index(request):
return {'hello': 'world'}
.. note::
A ``Content-Type`` header set to ``application/json`` is automatically added
to the response.
Redirects
^^^^^^^^^
The :func:`redirect <microdot.Response.redirect>` function is a helper that
creates redirect responses::
from microdot import redirect
@app.get('/')
async def index(request):
return redirect('/about')
File Responses
^^^^^^^^^^^^^^
The :func:`send_file <microdot.Response.send_file>` function builds a response
object for a file::
from microdot import send_file
@app.get('/')
async def index(request):
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('/')
async def image(request):
return send_file('/static/image.jpg', max_age=3600) # in seconds
.. note::
Unlike other web frameworks, Microdot does not automatically configure a
route to serve static files. The following is an example route that can be
added to the application to serve static files from a *static* directory in
the project::
@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, max_age=86400)
Streaming Responses
^^^^^^^^^^^^^^^^^^^
Instead of providing a response as a single value, an application can opt to
return a response that is generated in chunks, by returning a Python generator.
The example below returns all the numbers in the fibonacci sequence below 100::
@app.get('/fibonacci')
async def fibonacci(request):
async def generate_fibonacci():
a, b = 0, 1
while a < 100:
yield str(a) + '\n'
a, b = b, a + b
return generate_fibonacci()
.. note::
Under CPython, the generator function can be a ``def`` or ``async def``
function, as well as a class-based generator.
Under MicroPython, asynchronous generator functions are not supported, so
only ``def`` generator functions can be used. Asynchronous class-based
generators are supported.
Changing the Default Response Content Type
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Microdot uses a ``text/plain`` content type by default for responses that do
not explicitly include the ``Content-Type`` header. The application can change
this default by setting the desired content type in the
:attr:`default_content_type <microdot.Response.default_content_type>` attribute
of the :class:`Response <microdot.Response>` class.
The example that follows configures the application to use ``text/html`` as
default content type::
from microdot import Response
Response.default_content_type = 'text/html'
Setting Cookies
^^^^^^^^^^^^^^^
Many web applications rely on cookies to maintain client state between
requests. Cookies can be set with the ``Set-Cookie`` header in the response,
but since this is such a common practice, Microdot provides the
:func:`set_cookie() <microdot.Response.set_cookie>` method in the response
object to add a properly formatted cookie header to the response.
Given that route functions do not normally work directly with the response
object, the recommended way to set a cookie is to do it in a
:ref:`request-specific after-request handler <Request-Specific After-Request Handlers>`.
Example::
@app.get('/')
async def index(request):
@request.after_request
async def set_cookie(request, response):
response.set_cookie('name', 'value')
return response
return 'Hello, World!'
Another option is to create a response object directly in the route function::
@app.get('/')
async def index(request):
response = Response('Hello, World!')
response.set_cookie('name', 'value')
return response
.. note::
Standard cookies do not offer sufficient privacy and security controls, so
never store sensitive information in them unless you are adding additional
protection mechanisms such as encryption or cryptographic signing. The
:ref:`session <Maintaining Secure User Sessions>` extension implements signed
cookies that prevent tampering by malicious actors.
Concurrency
~~~~~~~~~~~
Microdot implements concurrency through the ``asyncio`` package. Applications
must ensure their handlers do not block, as this will prevent other concurrent
requests from being handled.
When running under CPython, ``async def`` handler functions run as native
asyncio tasks, while ``def`` handler functions are executed in a
`thread executor <https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor>`_
to prevent them from blocking the asynchronous loop.
Under MicroPython the situation is different. Most microcontroller boards
implementing MicroPython do not have threading support or executors, so ``def``
handler functions in this platform can only run in the main and only thread.
These functions will block the asynchronous loop when they take too long to
complete so ``async def`` handlers properly written to allow other handlers to
run in parallel should be preferred.

View File

@@ -0,0 +1,36 @@
Concurrency
~~~~~~~~~~~
Microdot implements concurrency through the ``asyncio`` package, which means
that applications must be careful to prevent blocking in their handlers.
"async def" handlers
^^^^^^^^^^^^^^^^^^^^
The recommendation for route handlers in Microdot is to use asynchronous
functions, declared as ``async def``. Microdot executes these handler
functions as native asynchronous tasks. The standard considerations for writing
asynchronous code apply, and in particular blocking calls should be avoided to
ensure the application runs smoothly and is always responsive.
"def" handlers
^^^^^^^^^^^^^^
Microdot also supports the use of synchronous route handlers, declared as
standard ``def`` functions. These handlers are handled differently under
CPython and MicroPython.
When running on CPython, Microdot executes synchronous handlers in a
`thread executor <https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor>`_,
which uses a thread pool. The use of blocking or CPU intensive code in these
handlers does not have such a negative effect on the application, because
handlers do not run on the same thread as the asynchronous loop. On the other
hand, the application will be affected by threading issues such as those caused
by the Global Interpreter Lock.
Under MicroPython the situation is different. Most microcontroller boards
do not have or have very limited threading support, so Microdot executes
synchronous handlers in the main and often only thread available. This means
that these functions will block the asynchronous loop when they take too long
to complete. The use of properly written asynchronous handlers should be
preferred.

View File

@@ -0,0 +1,378 @@
Defining Routes
~~~~~~~~~~~~~~~
In Microdot, routes define the logic of the web application.
The route decorator
^^^^^^^^^^^^^^^^^^^
The :func:`route() <microdot.Microdot.route>` decorator is used to associate an
application URL with the function that handles it. The only required argument
to the decorator is the path portion of the URL.
The following example creates a route for the root URL of the application::
@app.route('/')
async def index(request):
return 'Hello, world!'
When a client requests the root URL (for example, *http://localhost:5000/*),
Microdot will call the ``index()`` function, passing it a
:class:`Request <microdot.Request>` object. The return value of the function
is the response that is sent to the client.
Below is another example, this one with a route for a URL with two components
in its path::
@app.route('/users/active')
async def active_users(request):
return 'Active users: Susan, Joe, and Bob'
The complete URL that maps to this route is
*http://localhost:5000/users/active*.
An application can define multiple routes. Microdot uses the path portion of
the URL to determine the correct route function to call for each incoming
request.
Choosing the HTTP Method
^^^^^^^^^^^^^^^^^^^^^^^^
All the example routes shown above are associated with ``GET`` requests, which
are the default. Applications often need to define routes for other HTTP
methods, such as ``POST``, ``PUT``, ``PATCH`` and ``DELETE``. The ``route()``
decorator takes a ``methods`` optional argument, in which the application can
provide a list of HTTP methods that the route should be associated with on the
given path.
The following example defines a route that handles ``GET`` and ``POST``
requests within the same function::
@app.route('/invoices', methods=['GET', 'POST'])
async def invoices(request):
if request.method == 'GET':
return 'get invoices'
elif request.method == 'POST':
return 'create an invoice'
As an alternative to the example above, in which a single function is used to
handle multiple HTTP methods, sometimes it may be desirable to write a separate
function for each HTTP method. The above example can be implemented with two
routes as follows::
@app.route('/invoices', methods=['GET'])
async def get_invoices(request):
return 'get invoices'
@app.route('/invoices', methods=['POST'])
async def create_invoice(request):
return 'create an invoice'
Microdot provides the :func:`get() <microdot.Microdot.get>`,
:func:`post() <microdot.Microdot.post>`, :func:`put() <microdot.Microdot.put>`,
:func:`patch() <microdot.Microdot.patch>`, and
:func:`delete() <microdot.Microdot.delete>` decorators as shortcuts for the
corresponding HTTP methods. The two example routes above can be written more
concisely with them::
@app.get('/invoices')
async def get_invoices(request):
return 'get invoices'
@app.post('/invoices')
async def create_invoice(request):
return 'create an invoice'
Including Dynamic Components in the URL Path
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The examples shown above all use hardcoded URL paths. Microdot also supports
the definition of routes that have dynamic components in the path. For example,
the following route associates all URLs that have a path following the pattern
*http://localhost:5000/users/<username>* with the ``get_user()`` function::
@app.get('/users/<username>')
async def get_user(request, username):
return 'User: ' + username
As shown in the example, a path component that is enclosed in angle brackets
is considered a placeholder. Microdot accepts any values for that portion of
the URL path, and passes the value received to the function as an argument
after the request object.
Routes are not limited to a single dynamic component. The following route shows
how multiple dynamic components can be included in the path::
@app.get('/users/<firstname>/<lastname>')
async def get_user(request, firstname, lastname):
return 'User: ' + firstname + ' ' + lastname
Dynamic path components are considered to be strings by default. An explicit
type can be specified as a prefix, separated from the dynamic component name by
a colon. The following route has two dynamic components declared as an integer
and a string respectively::
@app.get('/users/<int:id>/<string:username>')
async def get_user(request, id, username):
return 'User: ' + username + ' (' + str(id) + ')'
If a dynamic path component is defined as an integer, the value passed to the
route function is also an integer. If the client sends a value that is not an
integer in the corresponding section of the URL path, then the URL will not
match and the route will not be called.
A special type ``path`` can be used to capture the remainder of the path as a
single argument. The difference between an argument of type ``path`` and one of
type ``string`` is that the latter stops capturing when a ``/`` appears in the
URL::
@app.get('/tests/<path:path>')
async def get_test(request, path):
return 'Test: ' + path
The ``re`` type allows the application to provide a custom regular expression
for the dynamic component. The next example defines a route that only matches
usernames that begin with an upper or lower case letter, followed by a sequence
of letters or numbers::
@app.get('/users/<re:[a-zA-Z][a-zA-Z0-9]*:username>')
async def get_user(request, username):
return 'User: ' + username
The ``re`` type returns the URL component as a string, which sometimes may not
be the most convenient. To convert a path component to something more
meaningful than a string, the application can register a custom URL component
type and provide a parser function that performs the conversion. In the
following example, a ``hex`` custom type is registered to automatically
convert hex numbers given in the path to numbers::
from microdot import URLPattern
URLPattern.register_type('hex', parser=lambda value: int(value, 16))
@app.get('/users/<hex:user_id>')
async def get_user(request, user_id):
user = get_user_by_id(user_id)
# ...
In addition to the parser, the custom URL component can include a pattern,
given as a regular expression. When a pattern is provided, the URL component
will only match if the regular expression matches the value passed in the URL.
The ``hex`` example above can be expanded with a pattern as follows::
URLPattern.register_type('hex', pattern='[0-9a-fA-F]+',
parser=lambda value: int(value, 16))
In cases where a pattern isn't provided, or when the pattern is unable to
filter out all invalid values, the parser function can return ``None`` to
indicate a failed match. The next example shows how the parser for the ``hex``
type can be expanded to do that::
def hex_parser(value):
try:
return int(value, 16)
except ValueError:
return None
URLPattern.register_type('hex', parser=hex_parser)
.. note::
Dynamic path components are passed to route functions as keyword arguments,
so the names of the function arguments must match the names declared in the
path specification.
Before and After Request Handlers
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
It is common for applications to need to perform one or more actions before a
request is handled. Examples include authenticating and/or authorizing the
client, opening a connection to a database, or checking if the requested
resource can be obtained from a cache. The
:func:`before_request() <microdot.Microdot.before_request>` decorator registers
a function to be called before the request is dispatched to the route function.
The following example registers a before-request handler that ensures that the
client is authenticated before the request is handled::
@app.before_request
async def authenticate(request):
user = authorize(request)
if not user:
return 'Unauthorized', 401
request.g.user = user
Before-request handlers receive the request object as an argument. If the
function returns a value, Microdot sends it to the client as the response, and
does not invoke the route function. This gives before-request handlers the
power to intercept a request if necessary. The example above uses this
technique to prevent an unauthorized user from accessing the requested
route.
After-request handlers registered with the
:func:`after_request() <microdot.Microdot.after_request>` decorator are called
after the route function returns a response. Their purpose is to perform any
common closing or cleanup tasks. The next example shows a combination of
before- and after-request handlers that print the time it takes for a request
to be handled::
@app.before_request
async def start_timer(request):
request.g.start_time = time.time()
@app.after_request
async def end_timer(request, response):
duration = time.time() - request.g.start_time
print(f'Request took {duration:0.2f} seconds')
After-request handlers receive the request and response objects as arguments,
and they can return a modified response object to replace the original. If
no value is returned from an after-request handler, then the original response
object is 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 after performing any necessary
cleanup.
.. note::
The :ref:`request.g <The "g" Object>` object used in many of the above
examples is a special object that allows the before- and after-request
handlers, as well as the route function to share data during the life of the
request.
Error Handlers
^^^^^^^^^^^^^^
When an error occurs during the handling of a request, Microdot ensures that
the client receives an appropriate error response. Some of the common errors
automatically handled by Microdot are:
- 400 for malformed requests.
- 404 for URLs that are unknown.
- 405 for URLs that are known, but not implemented for the requested HTTP
method.
- 413 for requests that are larger than the allowed size.
- 500 when the application raises an unhandled exception.
While the above errors are fully complaint with the HTTP specification, the
application might want to provide custom responses for them. The
:func:`errorhandler() <microdot.Microdot.errorhandler>` decorator registers
functions to respond to specific error codes. The following example shows a
custom error handler for 404 errors::
@app.errorhandler(404)
async def not_found(request):
return {'error': 'resource not found'}, 404
The ``errorhandler()`` decorator has a second form, in which it takes an
exception class as an argument. Microdot will invoke the handler when an
unhandled exception that is an instance of the given class is raised. The next
example provides a custom response for division by zero errors::
@app.errorhandler(ZeroDivisionError)
async def division_by_zero(request, exception):
return {'error': 'division by zero'}, 500
When the raised exception class does not have an error handler defined, but
one or more of its parent classes do, Microdot makes an attempt to invoke the
most specific handler.
Mounting a Sub-Application
^^^^^^^^^^^^^^^^^^^^^^^^^^
Small Microdot applications can be written as a single source file, but this
is not the best option for applications that pass a certain size. To make it
simpler to write large applications, Microdot supports the concept of
sub-applications that can be "mounted" on a larger application, possibly with
a common URL prefix applied to all of its routes. For developers familiar with
the Flask framework, this is a similar concept to Flask's blueprints.
Consider, for example, a *customers.py* sub-application that implements
operations on customers::
from microdot import Microdot
customers_app = Microdot()
@customers_app.get('/')
async def get_customers(request):
# return all customers
@customers_app.post('/')
async def new_customer(request):
# create a new customer
Similar to the above, the *orders.py* sub-application implements operations on
customer orders::
from microdot import Microdot
orders_app = Microdot()
@orders_app.get('/')
async def get_orders(request):
# return all orders
@orders_app.post('/')
async def new_order(request):
# create a new order
Now the main application, which is stored in *main.py*, can import and mount
the sub-applications to build the larger combined application::
from microdot import Microdot
from customers import customers_app
from orders import orders_app
def create_app():
app = Microdot()
app.mount(customers_app, url_prefix='/customers')
app.mount(orders_app, url_prefix='/orders')
return app
app = create_app()
app.run()
The resulting application will have the customer endpoints available at
*/customers/* and the order endpoints available at */orders/*.
.. note::
During the handling of a request, the
:attr:`Request.url_prefix <microdot.Microdot.url_prefix>` attribute is
set to the URL prefix under which the sub-application was mounted, or an
empty string if the endpoint did not come from a sub-application or the
sub-application was mounted without a URL prefix. It is possible to issue a
redirect that is relative to the sub-application as follows::
return redirect(request.url_prefix + '/relative-url')
When mounting an application as shown above, before-request, after-request and
error handlers defined in the sub-application are copied over to the main
application at mount time. Once installed in the main application, these
handlers will apply to the whole application and not just the sub-application
in which they were created.
The :func:`mount() <microdot.Microdot.mount>` method has a ``local`` argument
that defaults to ``False``. When this argument is set to ``True``, the
before-request, after-request and error handlers defined in the sub-application
will only apply to the sub-application.
Shutting Down the Server
^^^^^^^^^^^^^^^^^^^^^^^^
Web servers are designed to run forever, and are often stopped by sending them
an interrupt signal. But having a way to gracefully stop the server is
sometimes useful, especially in testing environments. Microdot provides a
:func:`shutdown() <microdot.Microdot.shutdown>` method that can be invoked
during the handling of a route to gracefully shut down the server when that
request completes. The next example shows how to use this feature::
@app.get('/shutdown')
async def shutdown(request):
request.app.shutdown()
return 'The server is shutting down...'
The request that invokes the ``shutdown()`` method will complete, and then the
server will not accept any new requests and stop once any remaining requests
complete. At this point the ``app.run()`` call will return.

View File

@@ -0,0 +1,18 @@
User's Guide
------------
This section describes the main features of Microdot.
.. toctree::
:maxdepth: 1
intro
defining-routes
request-object
responses
concurrency
For detailed reference information, consult the :ref:`API Reference`.
If you are familiar with releases of Microdot before 2.x, review the
:ref:`Migration Guide <Migrating to Microdot 2.x from Older Releases>`.

160
docs/users-guide/intro.rst Normal file
View File

@@ -0,0 +1,160 @@
Introduction
~~~~~~~~~~~~
This section covers how to create and run a basic Microdot web application.
A simple web server
^^^^^^^^^^^^^^^^^^^
The following is an example of a simple web server::
from microdot import Microdot
app = Microdot()
@app.route('/')
async def index(request):
return 'Hello, world!'
app.run()
The script imports the :class:`Microdot <microdot.Microdot>` class and creates
an application instance from it.
The application instance provides a :func:`route() <microdot.Microdot.route>`
decorator, which is used to define one or more routes, as needed by the
application.
The ``route()`` decorator takes the path portion of the URL as an
argument, and maps it to the decorated function, so that the function is called
when the client requests the URL.
When the function is called, it is passed a :class:`Request <microdot.Request>`
object as an argument, which provides access to the information passed by the
client. The value returned by the function is sent back to the client as the
response.
Microdot is an asynchronous framework that uses the ``asyncio`` package. Route
handler functions can be defined as ``async def`` or ``def`` functions, but
``async def`` functions are recommended for performance.
The :func:`run() <microdot.Microdot.run>` method starts the application's web
server on port 5000 by default, and creates its own asynchronous loop. This
method blocks while it waits for connections from clients.
For some applications it may be necessary to run the web server alongside other
asynchronous tasks, on an already running loop. In that case, instead of
``app.run()`` the web server can be started by invoking the
:func:`start_server() <microdot.Microdot.start_server>` coroutine as shown in
the following example::
import asyncio
from microdot import Microdot
app = Microdot()
@app.route('/')
async def index(request):
return 'Hello, world!'
async def main():
# start the server in a background task
server = asyncio.create_task(app.start_server())
# ... do other asynchronous work here ...
# cleanup before ending the application
await server
asyncio.run(main())
Running with CPython
^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
* - Required external dependencies
- | None
* - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello.py>`_
When using CPython, you can start the web server by running the script that
has the ``app.run()`` call at the bottom::
python main.py
After starting the script, open a web browser and navigate to
*http://localhost:5000/* to access the application at the default address for
the Microdot web server. From other computers in the same network, use the IP
address or hostname of the computer running the script instead of
``localhost``.
Running with MicroPython
^^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
* - Required external dependencies
- | None
* - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello.py>`_
| `gpio.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/gpio/gpio.py>`_
When using MicroPython, you can upload a *main.py* file containing the web
server code to your device, along with the required Microdot files, as defined
in the :ref:`MicroPython Installation` section.
MicroPython will automatically run *main.py* when the device is powered on, so
the web server will automatically start. The application can be accessed on
port 5000 at the device's IP address. As indicated above, the port can be
changed by passing the ``port`` argument to the ``run()`` method.
.. note::
Microdot does not configure the network interface of the device in which it
is running. If your device requires a network connection to be made in
advance, for example to a Wi-Fi access point, this must be configured before
the ``run()`` method is invoked.
Web Server Configuration
^^^^^^^^^^^^^^^^^^^^^^^^
The :func:`run() <microdot.Microdot.run>` and
:func:`start_server() <microdot.Microdot.start_server>` methods support a few
arguments to configure the web server.
- ``port``: The port number to listen on. Pass the desired port number in this
argument to use a port different than the default of 5000. For example::
app.run(port=6000)
- ``host``: The IP address of the network interface to listen on. By default
the server listens on all available interfaces. To listen only on the local
loopback interface, pass ``'127.0.0.1'`` as value for this argument.
- ``debug``: when set to ``True``, the server ouputs logging information to the
console. The default is ``False``.
- ``ssl``: an ``SSLContext`` instance that configures the server to use TLS
encryption, or ``None`` to disable TLS use. The default is ``None``. The
following example demonstrates how to configure the server with an SSL
certificate stored in *cert.pem* and *key.pem* files::
import ssl
# ...
sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sslctx.load_cert_chain('cert.pem', 'key.pem')
app.run(port=4443, debug=True, ssl=sslctx)
.. note::
When using CPython, the certificate and key files must be given in PEM
format. When using MicroPython, these files must be given in DER format.

View File

@@ -0,0 +1,169 @@
The Request Object
~~~~~~~~~~~~~~~~~~
The :class:`Request <microdot.Request>` object encapsulates all the information
passed by the client. It is passed as an argument to route handlers, as well as
to before-request, after-request and error handlers.
Request Attributes
^^^^^^^^^^^^^^^^^^
The request object provides access to the request attributes, including:
- :attr:`method <microdot.Request.method>`: The HTTP method of the request.
- :attr:`path <microdot.Request.path>`: The path of the request.
- :attr:`args <microdot.Request.args>`: The query string parameters of the
request, as a :class:`MultiDict <microdot.MultiDict>` object.
- :attr:`headers <microdot.Request.headers>`: The headers of the request, as a
dictionary.
- :attr:`cookies <microdot.Request.cookies>`: The cookies that the client sent
with the request, as a dictionary.
- :attr:`content_type <microdot.Request.content_type>`: The content type
specified by the client, or ``None`` if no content type was specified.
- :attr:`content_length <microdot.Request.content_length>`: The content
length of the request, or 0 if no content length was specified.
- :attr:`json <microdot.Request.json>`: The parsed JSON data in the request
body. See :ref:`below <JSON Payloads>` for additional details.
- :attr:`form <microdot.Request.form>`: The parsed form data in the request
body, as a dictionary. See :ref:`below <Form Data>` for additional details.
- :attr:`files <microdot.Request.files>`: A dictionary with the file uploads
included in the request body. Note that file uploads are only supported when
the :ref:`Multipart Forms` extension is used.
- :attr:`client_addr <microdot.Request.client_addr>`: The network address of
the client, as a tuple (host, port).
- :attr:`app <microdot.Request.app>`: The application instance that created the
request.
- :attr:`g <microdot.Request.g>`: The ``g`` object, where handlers can store
request-specific data to be shared among handlers. See :ref:`The "g" Object`
for details.
JSON Payloads
^^^^^^^^^^^^^
When the client sends a request that contains JSON data in the body, the
application can access the parsed JSON data using the
:attr:`json <microdot.Request.json>` attribute. The following example shows how
to use this attribute::
@app.post('/customers')
async def create_customer(request):
customer = request.json
# do something with customer
return {'success': True}
.. note::
The client must set the ``Content-Type`` header to ``application/json`` for
the ``json`` attribute of the request object to be populated.
Form Data
^^^^^^^^^
The request object also supports standard HTML form submissions through the
:attr:`form <microdot.Request.form>` attribute, which presents the form data
as a :class:`MultiDict <microdot.MultiDict>` object. Example::
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = 'Unknown'
if req.method == 'POST':
name = req.form.get('name')
return f'Hello {name}'
.. note::
Form submissions automatically parsed when the ``Content-Type`` header is
set by the client to ``application/x-www-form-urlencoded``. For form
submissions that use the ``multipart/form-data`` content type the
:ref:`Multipart Forms` extension must be used.
Accessing the Raw Request Body
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For cases in which neither JSON nor form data is expected, the
:attr:`body <microdot.Request.body>` request attribute returns the entire body
of the request as a byte sequence.
If the expected body is too large to fit safely in memory, the application can
use the :attr:`stream <microdot.Request.stream>` request attribute to read the
body contents as a file-like object. The
:attr:`max_body_length <microdot.Request.max_body_length>` attribute of the
request object defines the size at which bodies are streamed instead of loaded
into memory.
Cookies
^^^^^^^
Cookies that are sent by the client are made available through the
:attr:`cookies <microdot.Request.cookies>` attribute of the request object in
dictionary form.
The "g" Object
^^^^^^^^^^^^^^
Sometimes applications need to store data during the lifetime of a request, so
that it can be shared between the before- and after-request handlers, the
route function and any error handlers. The request object provides the
:attr:`g <microdot.Request.g>` attribute for that purpose.
In the following example, a before request handler authorizes the client and
stores the username so that the route function can use it::
@app.before_request
async def authorize(request):
username = authenticate_user(request)
if not username:
return 'Unauthorized', 401
request.g.username = username
@app.get('/')
async def index(request):
return f'Hello, {request.g.username}!'
Request-Specific After-Request Handlers
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Sometimes applications need to perform operations on the response object
before it is sent to the client, for example to set or remove a cookie. A good
option to use for this is to define a request-specific after-request handler
using the :func:`after_request <microdot.Microdot.after_request>` decorator.
Request-specific after-request handlers are called by Microdot after the route
function returns and all the application-wide after-request handlers have been
called.
The next example shows how a cookie can be updated using a request-specific
after-request handler defined inside a route function::
@app.post('/logout')
async def logout(request):
@request.after_request
def reset_session(request, response):
response.set_cookie('session', '', http_only=True)
return response
return 'Logged out'
Request Limits
^^^^^^^^^^^^^^
To help prevent malicious attacks, Microdot provides some configuration options
to limit the amount of information that is accepted:
- :attr:`max_content_length <microdot.Request.max_content_length>`: The
maximum size accepted for the request body, in bytes. When a client sends a
request that is larger than this, the server will respond with a 413 error.
The default is 16KB.
- :attr:`max_body_length <microdot.Request.max_body_length>`: The maximum
size that is loaded in the :attr:`body <microdot.Request.body>` attribute, in
bytes. Requests that have a body that is larger than this size but smaller
than the size set for ``max_content_length`` can only be accessed through the
:attr:`stream <microdot.Request.stream>` attribute. The default is also 16KB.
- :attr:`max_readline <microdot.Request.max_readline>`: The maximum allowed
size for a request line, in bytes. The default is 2KB.
The following example configures the application to accept requests with
payloads up to 1MB in size, but prevents requests that are larger than 8KB from
being loaded into memory::
from microdot import Request
Request.max_content_length = 1024 * 1024
Request.max_body_length = 8 * 1024

View File

@@ -0,0 +1,200 @@
Responses
~~~~~~~~~
The value or values that are returned from the route function are used by
Microdot to build the response that is sent to the client. The following
sections describe the different types of responses that are supported.
The Three Parts of a Response
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Route functions can return one, two or three values. The first and most
important value is the response body::
@app.get('/')
async def index(request):
return 'Hello, World!'
In the above example, Microdot issues a standard 200 status code response
indicating a successful request. The body of the response is the
``'Hello, World!'`` string returned by the function. Microdot includes default
headers with this response, including the ``Content-Type`` header set to
``text/plain`` to indicate a response in plain text.
The application can provide its own status code as a second value returned from
the route to override the 200 default. The example below returns a 202 status
code::
@app.get('/')
async def index(request):
return 'Hello, World!', 202
The application can also return a third value, a dictionary with additional
headers that are added to, or replace the default ones included by Microdot.
The next example returns an HTML response, instead of the default plain text
response::
@app.get('/')
async def index(request):
return '<h1>Hello, World!</h1>', 202, {'Content-Type': 'text/html'}
If the application does not need to return a body, then it can omit it and
have the status code as the first or only returned value::
@app.get('/')
async def index(request):
return 204
Likewise, if the application needs to return a body and custom headers, but
does not need to change the default status code, then it can return two values,
omitting the status code::
@app.get('/')
async def index(request):
return '<h1>Hello, World!</h1>', {'Content-Type': 'text/html'}
Lastly, the application can also return a :class:`Response <microdot.Response>`
object containing all the details of the response as a single value.
JSON Responses
^^^^^^^^^^^^^^
If the application needs to return a response with JSON formatted data, it can
return a dictionary or a list as the first value, and Microdot will
automatically format the response as JSON.
Example::
@app.get('/')
async def index(request):
return {'hello': 'world'}
.. note::
A ``Content-Type`` header set to ``application/json`` is automatically added
to the response.
Redirects
^^^^^^^^^
The :func:`redirect <microdot.Response.redirect>` function is a helper that
creates redirect responses::
from microdot import redirect
@app.get('/')
async def index(request):
return redirect('/about')
File Responses
^^^^^^^^^^^^^^
The :func:`send_file <microdot.Response.send_file>` function builds a response
object for a file::
from microdot import send_file
@app.get('/')
async def index(request):
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('/')
async def image(request):
return send_file('/static/image.jpg', max_age=3600) # in seconds
.. note::
Unlike other web frameworks, Microdot does not automatically configure a
route to serve static files. The following is an example route that can be
added to the application to serve static files from a *static* directory in
the project::
@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, max_age=86400)
Streaming Responses
^^^^^^^^^^^^^^^^^^^
Instead of providing a response as a single value, an application can opt to
return a response that is generated in chunks, by returning a Python generator.
The example below returns all the numbers in the fibonacci sequence below 100::
@app.get('/fibonacci')
async def fibonacci(request):
async def generate_fibonacci():
a, b = 0, 1
while a < 100:
yield str(a) + '\n'
a, b = b, a + b
return generate_fibonacci()
.. note::
Under CPython, the generator function can be a ``def`` or ``async def``
function, as well as a class-based generator.
Under MicroPython, asynchronous generator functions are not supported, so
only ``def`` generator functions can be used. Asynchronous class-based
generators are supported.
Changing the Default Response Content Type
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Microdot uses a ``text/plain`` content type by default for responses that do
not explicitly include the ``Content-Type`` header. The application can change
this default by setting the desired content type in the
:attr:`default_content_type <microdot.Response.default_content_type>` attribute
of the :class:`Response <microdot.Response>` class.
The example that follows configures the application to use ``text/html`` as
default content type::
from microdot import Response
Response.default_content_type = 'text/html'
Setting Cookies
^^^^^^^^^^^^^^^
Many web applications rely on cookies to maintain client state between
requests. Cookies can be set with the ``Set-Cookie`` header in the response,
but since this is such a common practice, Microdot provides the
:func:`set_cookie() <microdot.Response.set_cookie>` method in the response
object to add a properly formatted cookie header to the response.
Given that route functions do not normally work directly with the response
object, the recommended way to set a cookie is to do it in a
:ref:`request-specific after-request handler <Request-Specific After-Request Handlers>`.
Example::
@app.get('/')
async def index(request):
@request.after_request
async def set_cookie(request, response):
response.set_cookie('name', 'value')
return response
return 'Hello, World!'
Another option is to create a response object directly in the route function::
@app.get('/')
async def index(request):
response = Response('Hello, World!')
response.set_cookie('name', 'value')
return response
.. note::
Standard cookies do not offer sufficient privacy and security controls, so
never store sensitive information in them unless you are adding additional
protection mechanisms such as encryption or cryptographic signing. The
:ref:`session <Maintaining Secure User Sessions>` extension implements signed
cookies that prevent tampering by malicious actors.

1
examples/auth/README.md Normal file
View File

@@ -0,0 +1 @@
This directory contains examples that demonstrate basic and token authentication.

View File

@@ -0,0 +1,31 @@
from microdot import Microdot
from microdot.auth import BasicAuth
from pbkdf2 import generate_password_hash, check_password_hash
# this example provides an implementation of the generate_password_hash and
# check_password_hash functions that can be used in MicroPython. On CPython
# there are many other options for password hashisng so there is no need to use
# this custom solution.
USERS = {
'susan': generate_password_hash('hello'),
'david': generate_password_hash('bye'),
}
app = Microdot()
auth = BasicAuth()
@auth.authenticate
async def check_credentials(request, username, password):
if username in USERS and check_password_hash(USERS[username], password):
return username
@app.route('/')
@auth
async def index(request):
return f'Hello, {request.g.current_user}!'
if __name__ == '__main__':
app.run(debug=True)

47
examples/auth/pbkdf2.py Normal file
View File

@@ -0,0 +1,47 @@
import os
import hashlib
# PBKDF2 secure password hashing algorithm obtained from:
# https://codeandlife.com/2023/01/06/how-to-calculate-pbkdf2-hmac-sha256-with-
# python,-example-code/
def sha256(b):
return hashlib.sha256(b).digest()
def ljust(b, n, f):
return b + f * (n - len(b))
def gethmac(key, content):
okeypad = bytes(v ^ 0x5c for v in ljust(key, 64, b'\0'))
ikeypad = bytes(v ^ 0x36 for v in ljust(key, 64, b'\0'))
return sha256(okeypad + sha256(ikeypad + content))
def pbkdf2(pwd, salt, iterations=1000):
U = salt + b'\x00\x00\x00\x01'
T = bytes(64)
for _ in range(iterations):
U = gethmac(pwd, U)
T = bytes(a ^ b for a, b in zip(U, T))
return T
# The number of iterations may need to be adjusted depending on the hardware.
# Lower numbers make the password hashing algorithm faster but less secure, so
# the largest number that can be tolerated should be used.
def generate_password_hash(password, salt=None, iterations=100000):
salt = salt or os.urandom(16)
dk = pbkdf2(password.encode(), salt, iterations)
return f'pbkdf2-hmac-sha256:{salt.hex()}:{iterations}:{dk.hex()}'
def check_password_hash(password_hash, password):
algorithm, salt, iterations, dk = password_hash.split(':')
iterations = int(iterations)
if algorithm != 'pbkdf2-hmac-sha256':
return False
return pbkdf2(password.encode(), salt=bytes.fromhex(salt),
iterations=iterations) == bytes.fromhex(dk)

View File

@@ -0,0 +1,26 @@
from microdot import Microdot
from microdot.auth import TokenAuth
app = Microdot()
auth = TokenAuth()
TOKENS = {
'susan-token': 'susan',
'david-token': 'david',
}
@auth.authenticate
async def check_token(request, token):
if token in TOKENS:
return TOKENS[token]
@app.route('/')
@auth
async def index(request):
return f'Hello, {request.g.current_user}!'
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -32,22 +32,22 @@ flask==3.0.0
# via
# -r requirements.in
# quart
gunicorn==22.0.0
gunicorn==23.0.0
# via -r requirements.in
h11==0.14.0
h11==0.16.0
# via
# hypercorn
# uvicorn
# wsproto
h2==4.1.0
h2==4.3.0
# via hypercorn
hpack==4.0.0
hpack==4.1.0
# via h2
humanize==4.9.0
# via -r requirements.in
hypercorn==0.15.0
# via quart
hyperframe==6.0.1
hyperframe==6.1.0
# via h2
idna==3.7
# via
@@ -57,7 +57,7 @@ itsdangerous==2.1.2
# via
# flask
# quart
jinja2==3.1.4
jinja2==3.1.6
# via
# flask
# quart
@@ -82,9 +82,9 @@ pydantic-core==2.14.5
# via pydantic
pyproject-hooks==1.0.0
# via build
quart==0.19.7
quart==0.20.0
# via -r requirements.in
requests==2.32.0
requests==2.32.4
# via -r requirements.in
sniffio==1.3.0
# via anyio
@@ -95,7 +95,7 @@ typing-extensions==4.9.0
# fastapi
# pydantic
# pydantic-core
urllib3==2.2.2
urllib3==2.6.0
# via requests
uvicorn==0.24.0.post1
# via -r requirements.in

41
examples/csrf/README.md Normal file
View File

@@ -0,0 +1,41 @@
# CSRF Example
This is a small example that demonstrates how the CSRF protection in Microdot
works.
## Running the example
Start by cloning the repostory or copying the two example files *app.py* and
*evil.py* to your computer. The only dependency these examples need to run is `microdot`, so create a virtual environment and run:
pip install microdot
You need two terminals. On the first one, run:
python app.py
To see the application open *http://localhost:5000* on your web browser. The
application allows you to make payments through a web form. Each payment that
you make reduces the balance in your account. Type an amount in the form field and press the "Issue Payment" button to see how the balance decreases.
Leave the application running. On the second terminal run:
python evil.py
Open a second browser tab and navigate to *http://localhost:5001*. This
application simulates a malicious web site that tries to steal money from your
account. It does this by sending a cross-site form submission to the above
application.
The application presents a form that fools you into thinking you can win some
money. Clicking the button triggers the cross-site request to the form in the
first application, with the payment amount set to $100.
Because the application has CSRF protection enabled, the cross-site request
fails.
If you want to see how the attack can succeed, open *app.py* in your editor and
comment out the line that creates the ``csrf`` object. Restart *app.py* in your
first terminal, then go back to the second browser tab and click the
"Win $100!" button again. You will now see that the form is submitted
successfully and your balance in the first application is decremented by $100.

40
examples/csrf/app.py Normal file
View File

@@ -0,0 +1,40 @@
from microdot import Microdot, redirect
from microdot.cors import CORS
from microdot.csrf import CSRF
app = Microdot()
cors = CORS(app, allowed_origins=['http://localhost:5000'])
csrf = CSRF(app, cors)
balance = 1000
@app.route('/', methods=['GET', 'POST'])
def index(request):
global balance
if request.method == 'POST':
try:
balance -= float(request.form['amount'])
except ValueError:
pass
return redirect('/')
page = f'''<!doctype html>
<html>
<head>
<title>CSRF Example</title>
</head>
<body>
<h1>CSRF Example</h1>
<p>You have ${balance:.02f}</p>
<form method="POST" action="">
Pay $<input type="text" name="amount" size="10" />
<input type="submit" value="Issue Payment" />
</form>
</body>
</html>'''
return page, {'Content-Type': 'text/html'}
if __name__ == '__main__':
app.run(debug=True)

25
examples/csrf/evil.py Normal file
View File

@@ -0,0 +1,25 @@
from microdot import Microdot
app = Microdot()
@app.route('/', methods=['GET', 'POST'])
def index(request):
page = '''<!doctype html>
<html>
<head>
<title>CSRF Example</title>
</head>
<body>
<h1>Evil Site</h1>
<form method="POST" action="http://localhost:5000">
<input type="hidden" name="amount" value="100" />
<input type="submit" value="Win $100!" />
</form>
</body>
</html>'''
return page, {'Content-Type': 'text/html'}
if __name__ == '__main__':
app.run(port=5001, debug=True)

View File

@@ -0,0 +1,104 @@
# Microdot Weather Dashboard
This example reports the temperature and humidity, both as a web application
and as a JSON API.
![Weather Dashboard Screenshot](screenshot.png)
## Requirements
- A microcontroller that supports MicroPython (e.g. ESP8266, ESP32, Raspberry
Pi Pico W, etc.)
- A DHT22 temperature and humidity sensor
- A breadboard and some jumper wires to create the circuit
## Circuit
Install the microconller and the DHT22 sensor on different parts of the
breadboard. Make the following connections with jumper wires:
- from a microcontroller power pin (3.3V or 5V) to the left pin of the DHT22
sensor.
- from a microcontroller `GND` pin to the right pin of the DHT22 sensor.
- from any available microcontroller GPIO pin to the middle pin of the DHT22
sensor. If the DHT22 sensor has 4 pins instead of 3, use the one on the left,
next to the pin receiving power.
The following diagram shows a possible wiring for this circuit using an ESP8266
microcontroller and the 4-pin variant of the DHT22. In this diagram the data
pin of the DHT22 sensor is connected to pin `D2` of the ESP8266, which is
assigned to GPIO #4. Note that the location of the pins in the microcontroller
board will vary depending on which microcontroller you use.
![Circuit diagram](circuit.png)
## Installation
Edit *config.py* as follows:
- Set the `DHT22_PIN` variable to the GPIO pin number connected to the sensor's
data pin. Make sure you consult the documentation for your microcontroller to
learn what number you should use for your chosen GPIO pin. In the example
diagram above, the value should be 4.
- Enter your Wi-Fi SSID name and password in this file.
Install MicroPython on your microcontroller board following instructions on the
MicroPython website. Then use a tool such as
[rshell](https://github.com/dhylands/rshell) to upload the following files to
the board:
- *main.py*
- *config.py*
- *index.html*
- *microdot.py*
You can find *microdot.py* in the *src/microdot* directory of this repository.
If you are using a low end microcontroller such as the ESP8266, it is quite
possible that the *microdot.py* file will fail to compile due to the
MicroPython compiler needing more RAM than available in the device. In that
case, you can install the `mpy-cross` Python package in your computer (same
version as your MicroPython firmware) and precompile this file. The precompiled
file will have the name *microdot.mpy*. Upload this file and remove
*microdot.py* from the device.
When the device is restarted after the files were uploaded, it will connect to
Wi-Fi and then start a web server on port 8000. One way to find out which IP
address was assigned to your device is to check your Wi-Fi's router
administration panel. Another option is to connect to the MicroPython REPL with
`rshell` or any other tool that you like, and then press Ctrl-D at the
MicroPython prompt to soft boot the device. The IP address is printed to the
terminal on startup.
You should not upload other *.py* files that exist in this directory to your
device. These files are used when running with emulated hardware.
## Trying out the application
Once the device is running the server, you can connect to it using a web
browser. For example, if your device's Wi-Fi connection was assigned the IP
address 192.168.0.145, type *http://192.168.0.45:8000/* in your browser's
address bar. Note it is *http://* and not *https://*. This example does not use
the TLS/SSL protocol.
To test the JSON API, you can use `curl` or your favorite HTTP client. The API
endpoint uses the */api* path, with the same URL as the main website. Here is
an example using `curl`:
```bash
$ curl http://192.168.0.145:8000/api
{"temperature": 21.6, "humidity": 58.9, "time": 1752444652}
```
The `temperature` value is given in degrees Celsius. The `humidity` value is
given as a percentage. The `time` value is a UNIX timestamp.
## Running in Emulation mode
You can run this application on your computer, directly from this directory.
When used in this way, the DHT22 hardware is emulated, and the temperature and
humidity values are randomly generated.
The only dependency that is needed for this application to run in emulation
mode is `microdot`, so make sure that is installed, or else add a copy of the
*microdot.py* from the *src/microdot* directory in this folder.

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -0,0 +1,3 @@
DHT22_PIN = 4 # GPIO pin for DHT22 sensor
WIFI_ESSID = 'your_wifi_ssid'
WIFI_PASSWORD = 'your_wifi_password'

View File

@@ -0,0 +1,26 @@
"""
DO NOT UPLOAD THIS FILE TO YOUR MICROPYTHON DEVICE
This module emulates MicroPython's DHT22 driver. It can be used when running
on a system without the DHT22 hardware.
The temperature and humidity values that are returned are random values.
"""
from random import random
class DHT22:
def __init__(self, pin):
self.pin = pin
def measure(self):
pass
def temperature(self):
"""Return a random temperature between 10 and 30 degrees Celsius."""
return random() * 20 + 10
def humidity(self):
"""Return a random humidity between 30 and 70 percent."""
return random() * 40 + 30

View File

@@ -0,0 +1,169 @@
<!doctype html>
<html>
<head>
<title>Microdot Weather Dashboard</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gauge.js/1.3.9/gauge.min.js" integrity="sha512-/gkYCBz4KVyJb3Shz6Z1kKu9Za5EdInNezzsm2O/DPvAYhCeIOounTzi7yuIF526z3rNZfIDxcx+rJAD07p8aA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<style>
html, body {
height: 95%;
font-family: Arial, sans-serif;
}
#container {
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
h1 {
font-size: 1.5em;
}
h1, p {
text-align: center;
}
table {
margin-left: auto;
margin-right: auto;
}
table h1 {
margin-top: 0;
}
table p {
margin: 0;
}
#temperature, #humidity {
width: 100%;
max-width: 400px;
aspect-ratio: 2;
}
</style>
</head>
<body>
<div id="container">
<h1>Microdot Weather Dashboard</h1>
<table cellspacing="0" cellpadding="0">
<tr>
<td>
<canvas id="temperature" width="400" height="200"></canvas>
</td>
<td>
<canvas id="humidity" width="400" height="200"></canvas>
</td>
</tr>
<tr>
<td>
<p>Temperature</p>
<h1><span id="temperature-text">??</span>°C</h1>
</td>
<td>
<p>Humidity</p>
<h1><span id="humidity-text">??</span>%</h1>
</td>
</tr>
<tr>
<td colspan="2">
<p><i>Last updated: <span id="time-text">...</span></i></p>
</td>
</tr>
</table>
</div>
<script>
// create the temperature gauge
let temperatureGauge = new Gauge(document.getElementById('temperature')).setOptions({
angle: 0,
lineWidth: 0.3,
radiusScale: 1,
pointer: {
length: 0.6,
strokeWidth: 0.035,
color: '#000000',
},
limitMax: false,
limitMin: false,
highDpiSupport: true,
staticLabels: {
font: "14px sans-serif",
labels: [-30, -20, -10, 0, 10, 20, 30, 40, 50],
color: "#000000",
fractionDigits: 0,
},
staticZones: [
{strokeStyle: "#85a6e8", min: -30, max: 0},
{strokeStyle: "#a5dde8", min: 0, max: 10},
{strokeStyle: "#a5e8a6", min: 10, max: 20},
{strokeStyle: "#e8d8a5", min: 20, max: 30},
{strokeStyle: "#e8a8a5", min: 30, max: 50},
],
renderTicks: {
divisions: 8,
divWidth: 1.1,
divLength: 0.7,
divColor: '#333333',
subDivisions: 4,
subLength: 0.3,
subWidth: 0.6,
subColor: '#666666'
}
});
temperatureGauge.maxValue = 50;
temperatureGauge.setMinValue(-30);
temperatureGauge.animationSpeed = 36;
temperatureGauge.set(0);
let humidityGauge = new Gauge(document.getElementById('humidity')).setOptions({
angle: 0,
lineWidth: 0.3,
radiusScale: 1,
pointer: {
length: 0.6,
strokeWidth: 0.035,
color: '#000000',
},
limitMax: false,
limitMin: false,
highDpiSupport: true,
staticLabels: {
font: "14px sans-serif",
labels: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
color: "#000000",
fractionDigits: 0,
},
staticZones: [
{strokeStyle: "#85a6e8", min: 0, max: 40},
{strokeStyle: "#a5e8a6", min: 40, max: 70},
{strokeStyle: "#e8a8a5", min: 70, max: 100},
],
renderTicks: {
divisions: 10,
divWidth: 1.1,
divLength: 0.7,
divColor: '#333333',
subDivisions: 4,
subLength: 0.3,
subWidth: 0.6,
subColor: '#666666'
}
});
humidityGauge.maxValue = 100;
humidityGauge.setMinValue(0);
humidityGauge.animationSpeed = 36;
humidityGauge.set(0);
async function update() {
const response = await fetch('/api');
if (response.ok) {
const data = await response.json();
temperatureGauge.set(data.temperature);
humidityGauge.set(data.humidity);
document.getElementById('temperature-text').textContent = data.temperature;
document.getElementById('humidity-text').textContent = data.humidity;
document.getElementById('time-text').textContent = new Date(data.time * 1000).toLocaleString();
}
setTimeout(update, 60000); // refresh every minute
}
update();
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
"""
DO NOT UPLOAD THIS FILE TO YOUR MICROPYTHON DEVICE
This module emulates parts of MicroPython's `machine` module, to enable to run
MicroPython applications on UNIX, Mac or Windows systems without dedicated
hardware.
"""
class Pin:
def __init__(self, pin):
self.pin = pin

View File

@@ -0,0 +1,112 @@
import asyncio
import dht
import gc
import machine
import network
import socket
import time
import config
from microdot import Microdot, send_file
app = Microdot()
current_temperature = None
current_humidity = None
current_time = None
def wifi_connect():
"""Connect to the configured Wi-Fi network.
Returns the IP address of the connected interface.
"""
ap_if = network.WLAN(network.AP_IF)
ap_if.active(False)
sta_if = network.WLAN(network.STA_IF)
if not sta_if.isconnected():
print('connecting to network...')
sta_if.active(True)
sta_if.connect(config.WIFI_ESSID, config.WIFI_PASSWORD)
for i in range(20):
if sta_if.isconnected():
break
time.sleep(1)
if not sta_if.isconnected():
raise RuntimeError('Could not connect to network')
return sta_if.ifconfig()[0]
def get_current_time():
"""Return the current Unix time.
Note that because many microcontrollers do not have a clock, this function
makes a call to an NTP server to obtain the current time. A Wi-Fi
connection needs to be in place before calling this function.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(5)
s.sendto(b'\x1b' + 47 * b'\0',
socket.getaddrinfo('pool.ntp.org', 123)[0][4])
msg, _ = s.recvfrom(1024)
return ((msg[40] << 24) | (msg[41] << 16) | (msg[42] << 8) | msg[43]) - \
2208988800
def get_current_weather():
"""Read the temperature and humidity from the DHT22 sensor.
Returns them as a tuple. The returned temperature is in degrees Celcius.
The humidity is a 0-100 percentage.
"""
d = dht.DHT22(machine.Pin(config.DHT22_PIN))
d.measure()
return d.temperature(), d.humidity()
async def refresh_weather():
"""Background task that updates the temperature and humidity.
This task is designed to run in the background. It connects to the DHT22
temperature and humidity sensor once per minute and stores the updated
readings in global variables.
"""
global current_temperature
global current_humidity
global current_time
while True:
try:
t = get_current_time()
temp, hum = get_current_weather()
except asyncio.CancelledError:
raise
except Exception as error:
print(f'Could not obtain weather, error: {error}')
else:
current_time = t
current_temperature = int(temp * 10) / 10
current_humidity = int(hum * 10) / 10
gc.collect()
await asyncio.sleep(60)
@app.route('/')
async def index(request):
return send_file('index.html')
@app.route('/api')
async def api(request):
return {
'temperature': current_temperature,
'humidity': current_humidity,
'time': current_time,
}
async def start():
ip = wifi_connect()
print(f'Starting server at http://{ip}:8000...')
bgtask = asyncio.create_task(refresh_weather())
server = asyncio.create_task(app.start_server(port=8000))
await asyncio.gather(server, bgtask)
asyncio.run(start())

View File

@@ -0,0 +1,31 @@
"""
DO NOT UPLOAD THIS FILE TO YOUR MICROPYTHON DEVICE
This module emulates parts of MicroPython's `network` module, in particular
those related to establishing a Wi-Fi connection. This enables to run
MicroPython applications on UNIX, Mac or Windows systems without dedicated
hardware.
Note that no connections are attempted. The assumption is that the system is
already connected. The "127.0.0.1" address is always returned.
"""
AP_IF = 1
STA_IF = 2
class WLAN:
def __init__(self, network):
self.network = network
def isconnected(self):
return True
def ifconfig(self):
return ('127.0.0.1', 'n/a', 'n/a', 'n/a')
def connect(self):
pass
def active(self, active=None):
pass

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

1
examples/login/README.md Normal file
View File

@@ -0,0 +1 @@
This directory contains examples that demonstrate user logins.

123
examples/login/login.py Normal file
View File

@@ -0,0 +1,123 @@
from microdot import Microdot, redirect
from microdot.session import Session
from microdot.login import Login
from pbkdf2 import generate_password_hash, check_password_hash
# this example provides an implementation of the generate_password_hash and
# check_password_hash functions that can be used in MicroPython. On CPython
# there are many other options for password hashisng so there is no need to use
# this custom solution.
class User:
def __init__(self, id, username, password):
self.id = id
self.username = username
self.password_hash = self.create_hash(password)
def create_hash(self, password):
return generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
USERS = {
'user001': User('user001', 'susan', 'hello'),
'user002': User('user002', 'david', 'bye'),
}
app = Microdot()
Session(app, secret_key='top-secret!')
login = Login()
@login.user_loader
async def get_user(user_id):
return USERS.get(user_id)
@app.route('/login', methods=['GET', 'POST'])
async def login_page(request):
if request.method == 'GET':
return '''
<!doctype html>
<html>
<body>
<h1>Please Login</h1>
<form method="POST">
<p>
Username<br>
<input name="username" autofocus>
</p>
<p>
Password:<br>
<input name="password" type="password">
<br>
</p>
<p>
<input name="remember_me" type="checkbox"> Remember me
<br>
</p>
<p>
<button type="submit">Login</button>
</p>
</form>
</body>
</html>
''', {'Content-Type': 'text/html'}
username = request.form['username']
password = request.form['password']
remember_me = bool(request.form.get('remember_me'))
for user in USERS.values():
if user.username == username:
if user.check_password(password):
return await login.login_user(request, user,
remember=remember_me)
return redirect('/login')
@app.route('/')
@login
async def index(request):
return f'''
<!doctype html>
<html>
<body>
<h1>Hello, {request.g.current_user.username}!</h1>
<p>
<a href="/fresh">Click here</a> to access the fresh login page.
</p>
<form method="POST" action="/logout">
<button type="submit">Logout</button>
</form>
</body>
</html>
''', {'Content-Type': 'text/html'}
@app.get('/fresh')
@login.fresh
async def fresh(request):
return f'''
<!doctype html>
<html>
<body>
<h1>Hello, {request.g.current_user.username}!</h1>
<p>This page requires a fresh login session.</p>
<p><a href="/">Go back</a> to the main page.</p>
</body>
</html>
''', {'Content-Type': 'text/html'}
@app.post('/logout')
@login
async def logout(request):
await login.logout_user(request)
return redirect('/')
if __name__ == '__main__':
app.run(debug=True)

47
examples/login/pbkdf2.py Normal file
View File

@@ -0,0 +1,47 @@
import os
import hashlib
# PBKDF2 secure password hashing algorithm obtained from:
# https://codeandlife.com/2023/01/06/how-to-calculate-pbkdf2-hmac-sha256-with-
# python,-example-code/
def sha256(b):
return hashlib.sha256(b).digest()
def ljust(b, n, f):
return b + f * (n - len(b))
def gethmac(key, content):
okeypad = bytes(v ^ 0x5c for v in ljust(key, 64, b'\0'))
ikeypad = bytes(v ^ 0x36 for v in ljust(key, 64, b'\0'))
return sha256(okeypad + sha256(ikeypad + content))
def pbkdf2(pwd, salt, iterations=1000):
U = salt + b'\x00\x00\x00\x01'
T = bytes(64)
for _ in range(iterations):
U = gethmac(pwd, U)
T = bytes(a ^ b for a, b in zip(U, T))
return T
# The number of iterations may need to be adjusted depending on the hardware.
# Lower numbers make the password hashing algorithm faster but less secure, so
# the largest number that can be tolerated should be used.
def generate_password_hash(password, salt=None, iterations=100000):
salt = salt or os.urandom(16)
dk = pbkdf2(password.encode(), salt, iterations)
return f'pbkdf2-hmac-sha256:{salt.hex()}:{iterations}:{dk.hex()}'
def check_password_hash(password_hash, password):
algorithm, salt, iterations, dk = password_hash.split(':')
iterations = int(iterations)
if algorithm != 'pbkdf2-hmac-sha256':
return False
return pbkdf2(password.encode(), salt=bytes.fromhex(salt),
iterations=iterations) == bytes.fromhex(dk)

View File

@@ -1,3 +1,6 @@
# This is a simple example that demonstrates how to use the user session, but
# is not intended as a complete login solution. See the login subdirectory for
# a more complete example.
from microdot import Microdot, Response, redirect
from microdot.session import Session, with_session

View File

@@ -1,16 +1,28 @@
import asyncio
from microdot import Microdot
from microdot import Microdot, send_file
from microdot.sse import with_sse
app = Microdot()
@app.route("/")
async def main(request):
return send_file('index.html')
@app.route('/events')
@with_sse
async def events(request, sse):
for i in range(10):
await asyncio.sleep(1)
await sse.send({'counter': i})
print('Client connected')
try:
i = 0
while True:
await asyncio.sleep(1)
i += 1
await sse.send({'counter': i})
except asyncio.CancelledError:
pass
print('Client disconnected')
app.run(debug=True)
app.run()

30
examples/sse/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>Microdot SSE Example</title>
<meta charset="UTF-8">
</head>
<body>
<h1>Microdot SSE Example</h1>
<div id="log"></div>
<script>
const log = (text, color) => {
document.getElementById('log').innerHTML += `<span style="color: ${color}">${text}</span><br>`;
};
const eventSource = new EventSource('/events');
eventSource.onopen = () => {
log('Connection to server opened.', 'black');
};
eventSource.onmessage = (event) => {
log(`Received message: ${event.data}`, 'blue');
};
eventSource.onerror = (event) => {
log(`EventSource failed: ${event.type}`, 'red');
};
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
This directory contains examples that demonstrate sub-applications.

27
examples/subapps/app.py Normal file
View File

@@ -0,0 +1,27 @@
from microdot import Microdot
from subapp import subapp
app = Microdot()
app.mount(subapp, url_prefix='/subapp')
@app.route('/')
async def hello(request):
return '''
<!DOCTYPE html>
<html>
<head>
<title>Microdot Sub-App Example</title>
<meta charset="UTF-8">
</head>
<body>
<div>
<h1>Microdot Main Page</h1>
<p>Visit the <a href="/subapp">sub-app</a>.</p>
</div>
</body>
</html>
''', 200, {'Content-Type': 'text/html'}
app.run(debug=True)

View File

@@ -0,0 +1,44 @@
from microdot import Microdot
subapp = Microdot()
@subapp.route('')
async def hello(request):
# request.url_prefix can be used in links that are relative to this subapp
return f'''
<!DOCTYPE html>
<html>
<head>
<title>Microdot Sub-App Example</title>
<meta charset="UTF-8">
</head>
<body>
<div>
<h1>Microdot Sub-App Main Page</h1>
<p>Visit the sub-app's <a href="{request.url_prefix}/second">secondary page</a>.</p>
<p>Go back to the app's <a href="/">main page</a>.</p>
</div>
</body>
</html>
''', 200, {'Content-Type': 'text/html'} # noqa: E501
@subapp.route('/second')
async def second(request):
return f'''
<!DOCTYPE html>
<html>
<head>
<title>Microdot Sub-App Example</title>
<meta charset="UTF-8">
</head>
<body>
<div>
<h1>Microdot Sub-App Secondary Page</h1>
<p>Visit the sub-app's <a href="{request.url_prefix}">main page</a>.</p>
<p>Go back to the app's <a href="/">main page</a>.</p>
</div>
</body>
</html>
''', 200, {'Content-Type': 'text/html'} # noqa: E501

View File

@@ -1 +1,4 @@
This directory contains file upload examples.
- `simple_uploads.py` demonstrates how to upload a single file.
- `formdata.py` demonstrates how to process a form that includes file uploads.

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<title>Microdot Multipart Form-Data Example</title>
<meta charset="UTF-8">
</head>
<body>
<h1>Microdot Multipart Form-Data Example</h1>
<form method="POST" action="" enctype="multipart/form-data">
<p>Name: <input type="text" name="name" /></p>
<p>Age: <input type="text" name="age" /></p>
<p>Comments: <textarea name="comments" rows="4"></textarea></p>
<p>File: <input type="file" id="file" name="file" /></p>
<input type="submit" value="Submit" />
</form>
</body>
</html>

View File

@@ -0,0 +1,26 @@
from microdot import Microdot, send_file, Request
from microdot.multipart import with_form_data
app = Microdot()
Request.max_content_length = 1024 * 1024 # 1MB (change as needed)
@app.get('/')
async def index(request):
return send_file('formdata.html')
@app.post('/')
@with_form_data
async def upload(request):
print('Form fields:')
for field, value in request.form.items():
print(f'- {field}: {value}')
print('\nFile uploads:')
for field, value in request.files.items():
print(f'- {field}: {value.filename}, {await value.read()}')
return 'We have received your data!'
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -6,7 +6,7 @@ Request.max_content_length = 1024 * 1024 # 1MB (change as needed)
@app.get('/')
async def index(request):
return send_file('index.html')
return send_file('simple_uploads.html')
@app.post('/upload')

View File

@@ -1,6 +1,6 @@
[project]
name = "microdot"
version = "2.0.8.dev0"
version = "2.5.1"
authors = [
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
]
@@ -31,6 +31,7 @@ dev = [
]
docs = [
"sphinx",
"furo",
"pyjwt",
]

View File

@@ -1,2 +1,4 @@
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
send_file # noqa: F401
send_file, URLPattern, AsyncBytesIO, iscoroutine # noqa: F401
__version__ = '2.5.1'

View File

@@ -48,15 +48,47 @@ class Microdot(BaseMicrodot):
"""A subclass of the core :class:`Microdot <microdot.Microdot>` class that
implements the ASGI protocol.
:param startup: An optional function to handle the `lifespan.startup` ASGI
signal.
:param shutdown: An optional function to handle the `lifespan.shutdown`
ASGI signal.
This class must be used as the application instance when running under an
ASGI web server.
"""
def __init__(self):
def __init__(self, lifespan_startup=None, lifespan_shutdown=None):
super().__init__()
self.lifespan_startup = lifespan_startup
self.lifespan_shutdown = lifespan_shutdown
self.embedded_server = False
async def handle_lifespan(self, scope, receive, send):
while True:
message = await receive()
if message['type'] == 'lifespan.startup':
try:
if self.lifespan_startup:
await self.lifespan_startup(scope)
except Exception as e:
await send({'type': 'lifespan.startup.failed',
'message': repr(e)})
else:
await send({'type': 'lifespan.startup.complete'})
elif message['type'] == 'lifespan.shutdown': # pragma: no branch
try:
if self.lifespan_shutdown:
await self.lifespan_shutdown(scope)
except Exception as e:
await send({'type': 'lifespan.shutdown.failed',
'message': repr(e)})
else:
await send({'type': 'lifespan.shutdown.complete'})
break
async def asgi_app(self, scope, receive, send):
"""An ASGI application."""
if scope['type'] == 'lifespan':
return await self.handle_lifespan(scope, receive, send)
if scope['type'] not in ['http', 'websocket']: # pragma: no cover
return
path = scope['path']
@@ -91,7 +123,8 @@ class Microdot(BaseMicrodot):
headers,
body=body,
stream=stream,
sock=(receive, send))
sock=(receive, send),
scheme=scope.get('scheme'))
req.asgi_scope = scope
res = await self.dispatch_request(req)
@@ -127,19 +160,19 @@ class Microdot(BaseMicrodot):
monitor_task = asyncio.ensure_future(cancel_monitor())
body_iter = res.body_iter().__aiter__()
res_body = b''
try:
res_body = await body_iter.__anext__()
while not cancelled: # pragma: no branch
next_body = await body_iter.__anext__()
res_body = await body_iter.__anext__()
if isinstance(res_body, str):
res_body = res_body.encode()
await send({'type': 'http.response.body',
'body': res_body,
'more_body': True})
res_body = next_body
except StopAsyncIteration:
await send({'type': 'http.response.body',
'body': res_body,
'more_body': False})
pass
await send({'type': 'http.response.body',
'body': b'',
'more_body': False})
if hasattr(body_iter, 'aclose'): # pragma: no branch
await body_iter.aclose()
cancelled = True

162
src/microdot/auth.py Normal file
View File

@@ -0,0 +1,162 @@
from microdot import abort
from microdot.microdot import invoke_handler
class BaseAuth:
def __init__(self):
self.auth_callback = None
self.error_callback = None
def __call__(self, f):
"""Decorator to protect a route with authentication.
An instance of this class must be used as a decorator on the routes
that need to be protected. Example::
auth = BasicAuth() # or TokenAuth()
@app.route('/protected')
@auth
def protected(request):
# ...
Routes that are decorated in this way will only be invoked if the
authentication callback returned a valid user object, otherwise the
error callback will be executed.
"""
async def wrapper(request, *args, **kwargs):
auth = self._get_auth(request)
if not auth:
return await invoke_handler(self.error_callback, request)
request.g.current_user = await invoke_handler(
self.auth_callback, request, *auth)
if not request.g.current_user:
return await invoke_handler(self.error_callback, request)
return await invoke_handler(f, request, *args, **kwargs)
return wrapper
def optional(self, f):
"""Decorator to protect a route with optional authentication.
This decorator makes authentication for the decorated route optional,
meaning that the route is allowed to run with or with
authentication given in the request.
"""
async def wrapper(request, *args, **kwargs):
auth = self._get_auth(request)
if not auth:
request.g.current_user = None
else:
request.g.current_user = await invoke_handler(
self.auth_callback, request, *auth)
return await invoke_handler(f, request, *args, **kwargs)
return wrapper
class BasicAuth(BaseAuth):
"""Basic Authentication.
:param realm: The realm that is displayed when the user is prompted to
authenticate in the browser.
:param charset: The charset that is used to encode the realm.
:param scheme: The authentication scheme. Defaults to 'Basic'.
:param error_status: The error status code to return when authentication
fails. Defaults to 401.
"""
def __init__(self, realm='Please login', charset='UTF-8', scheme='Basic',
error_status=401):
super().__init__()
self.realm = realm
self.charset = charset
self.scheme = scheme
self.error_status = error_status
self.error_callback = self.authentication_error
def _get_auth(self, request):
auth = request.headers.get('Authorization')
if auth and auth.startswith('Basic '):
import binascii
try:
username, password = binascii.a2b_base64(
auth[6:]).decode().split(':', 1)
except Exception: # pragma: no cover
return None
return username, password
async def authentication_error(self, request):
return '', self.error_status, {
'WWW-Authenticate': '{} realm="{}", charset="{}"'.format(
self.scheme, self.realm, self.charset)}
def authenticate(self, f):
"""Decorator to configure the authentication callback.
This decorator must be used with a function that accepts the request
object, a username and a password and returns a user object if the
credentials are valid, or ``None`` if they are not. Example::
@auth.authenticate
async def check_credentials(request, username, password):
user = get_user(username)
if user and user.check_password(password):
return get_user(username)
"""
self.auth_callback = f
class TokenAuth(BaseAuth):
"""Token based authentication.
:param header: The name of the header that will contain the token. Defaults
to 'Authorization'.
:param scheme: The authentication scheme. Defaults to 'Bearer'.
:param error_status: The error status code to return when authentication
fails. Defaults to 401.
"""
def __init__(self, header='Authorization', scheme='Bearer',
error_status=401):
super().__init__()
self.header = header
self.scheme = scheme.lower()
self.error_status = error_status
self.error_callback = self.authentication_error
def _get_auth(self, request):
auth = request.headers.get(self.header)
if auth:
if self.header == 'Authorization':
try:
scheme, token = auth.split(' ', 1)
except Exception:
return None
if scheme.lower() == self.scheme:
return (token.strip(),)
else:
return (auth,)
def authenticate(self, f):
"""Decorator to configure the authentication callback.
This decorator must be used with a function that accepts the request
object, a username and a password and returns a user object if the
credentials are valid, or ``None`` if they are not. Example::
@auth.authenticate
async def check_credentials(request, token):
return get_user(token)
"""
self.auth_callback = f
def errorhandler(self, f):
"""Decorator to configure the error callback.
Microdot calls the error callback to allow the application to generate
a custom error response. The default error response is to call
``abort(401)``.
"""
self.error_callback = f
async def authentication_error(self, request):
abort(self.error_status)

View File

@@ -104,7 +104,8 @@ class CORS:
def after_request(self, request, response):
saved_vary = response.headers.get('Vary')
response.headers.update(self.get_cors_headers(request))
if request: # pragma: no branch
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'])

120
src/microdot/csrf.py Normal file
View File

@@ -0,0 +1,120 @@
from microdot import abort
class CSRF:
"""CSRF protection for Microdot routes.
:param app: The application instance.
:param cors: The ``CORS`` instance that defines the origins that are
trusted by the application. This is used to validate requests
from older browsers that do not send the ``Sec-Fetch-Site``
header.
:param protect_all: If ``True``, all state changing routes are protected by
default, with the exception of routes that are
decorated with the :meth:`exempt <exempt>` decorator.
If ``False``, only routes decorated with the
:meth:`protect <protect>` decorator are protected. The
default is ``True``.
:param allow_subdomains: If ``True``, requests from subdomains of the
application domain are trusted. The default is
``False``.
CSRF protection is implemented by checking the ``Sec-Fetch-Site`` sent by
browsers. When the ``cors`` argument is provided, requests from older
browsers that do not support the ``Sec-Fetch-Site`` header are validated
by checking the ``Origin`` header.
"""
SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']
def __init__(self, app=None, cors=None, protect_all=True,
allow_subdomains=False):
self.cors = None
self.protect_all = protect_all
self.allow_subdomains = allow_subdomains
self.exempt_routes = []
self.protected_routes = []
if app is not None:
self.initialize(app, cors)
def initialize(self, app, cors=None):
"""Initialize the CSRF class.
:param app: The application instance.
:param cors: The ``CORS`` instance that defines the origins that are
trusted by the application. This is used to validate
requests from older browsers that do not send the
``Sec-Fetch-Site`` header.
"""
self.cors = cors
@app.before_request
async def csrf_before_request(request):
if (
self.protect_all
and request.method not in self.SAFE_METHODS
and request.route not in self.exempt_routes
) or request.route in self.protected_routes:
allow = False
sfs = request.headers.get('Sec-Fetch-Site')
origin = request.headers.get('Origin')
if sfs:
# if the Sec-Fetch-Site header was given, ensure it is not
# cross-site
if sfs in ['same-origin', 'none']:
allow = True
elif sfs == 'same-site' and self.allow_subdomains:
allow = True
if not allow and origin and self.cors and \
self.cors.allowed_origins != '*':
# if we have a list of allowed origins, then we can
# validate the origin
if not self.allow_subdomains:
allow = origin in self.cors.allowed_origins
else:
origin_scheme, origin_host = origin.split('://', 1)
for allowed_origin in self.cors.allowed_origins:
allowed_scheme, allowed_host = \
allowed_origin.split('://', 1)
if origin == allowed_origin or (
origin_host.endswith('.' + allowed_host)
and origin_scheme == allowed_scheme
):
allow = True
break
if not allow and not sfs and not origin:
allow = True # no headers to check
if not allow:
abort(403, 'Forbidden')
def exempt(self, f):
"""Decorator to exempt a route from CSRF protection.
This decorator must be added immediately after the route decorator to
disable CSRF protection on the route. Example::
@app.post('/submit')
@csrf.exempt
# add additional decorators here
def submit(request):
# ...
"""
self.exempt_routes.append(f)
return f
def protect(self, f):
"""Decorator to protect a route against CSRF attacks.
This is useful when it is necessary to protect a request that uses one
of the safe methods that are not supposed to make state changes. The
decorator must be added immediately after the route decorator to
disable CSRF protection on the route. Example::
@app.get('/data')
@csrf.force
# add additional decorators here
def get_data(request):
# ...
"""
self.protected_routes.append(f)
return f

View File

@@ -1,19 +1,27 @@
from jinja2 import Environment, FileSystemLoader, select_autoescape
_jinja_env = None
class Template:
"""A template object.
:param template: The filename of the template to render, relative to the
configured template directory.
:param kwargs: any additional options to be passed to the Jinja
environment's ``get_template()`` method.
"""
#: The Jinja environment. The ``initialize()`` method must be called before
#: this attribute is accessed.
jinja_env = None
@classmethod
def initialize(cls, template_dir='templates', enable_async=False,
**kwargs):
"""Initialize the templating subsystem.
This method is automatically invoked when the first template is
created. The application can call it explicitly if custom options need
to be provided.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load
templates from a *templates* subdirectory.
@@ -23,20 +31,19 @@ class Template:
:param kwargs: any additional options to be passed to Jinja's
``Environment`` class.
"""
global _jinja_env
_jinja_env = Environment(
cls.jinja_env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
enable_async=enable_async,
**kwargs
)
def __init__(self, template):
if _jinja_env is None: # pragma: no cover
def __init__(self, template, **kwargs):
if self.jinja_env is None: # pragma: no cover
self.initialize()
#: The name of the template
#: The name of the template.
self.name = template
self.template = _jinja_env.get_template(template)
self.template = self.jinja_env.get_template(template, **kwargs)
def generate(self, *args, **kwargs):
"""Return a generator that renders the template in chunks, with the

172
src/microdot/login.py Normal file
View File

@@ -0,0 +1,172 @@
from time import time
from microdot import redirect
from microdot.microdot import urlencode, invoke_handler
class Login:
"""User login support for Microdot.
:param login_url: the URL to redirect to when a login is required. The
default is '/login'.
"""
def __init__(self, login_url='/login'):
self.login_url = login_url
self.user_loader_callback = None
def user_loader(self, f):
"""Decorator to configure the user callback.
The decorated function receives the user ID as an argument and must
return the corresponding user object, or ``None`` if the user ID is
invalid.
"""
self.user_loader_callback = f
def _get_session(self, request):
return request.app._session.get(request)
def _update_remember_cookie(self, request, days, user_id=None):
remember_payload = request.app._session.encode({
'user_id': user_id,
'days': days,
'exp': time() + days * 24 * 60 * 60
})
@request.after_request
async def _set_remember_cookie(request, response):
response.set_cookie('_remember', remember_payload,
max_age=days * 24 * 60 * 60)
return response
def _get_user_id_from_session(self, request):
session = self._get_session(request)
if session and '_user_id' in session:
return session['_user_id']
if '_remember' in request.cookies:
remember_payload = request.app._session.decode(
request.cookies['_remember'])
user_id = remember_payload.get('user_id')
if user_id: # pragma: no branch
self._update_remember_cookie(
request, remember_payload.get('_days', 30), user_id)
session['_user_id'] = user_id
session['_fresh'] = False
session.save()
return user_id
async def _redirect_to_login(self, request):
return '', 302, {'Location': self.login_url + '?next=' + urlencode(
request.url)}
async def login_user(self, request, user, remember=False,
redirect_url='/'):
"""Log a user in.
:param request: the request object
:param user: the user object
:param remember: if the user's logged in state should be remembered
with a cookie after the session ends. Set to the
number of days the remember cookie should last, or to
``True`` to use a default duration of 30 days.
:param redirect_url: the URL to redirect to after login
This call marks the user as logged in by storing their user ID in the
user session. The application must call this method to log a user in
after their credentials have been validated.
The method returns a redirect response, either to the URL the user
originally intended to visit, or if there is no original URL to the URL
specified by the `redirect_url`.
"""
session = self._get_session(request)
session['_user_id'] = user.id
session['_fresh'] = True
session.save()
request.g.current_user = user
if remember:
days = 30 if remember is True else int(remember)
self._update_remember_cookie(request, days, session['_user_id'])
next_url = request.args.get('next', redirect_url)
if not next_url.startswith('/'):
next_url = redirect_url
return redirect(next_url)
async def logout_user(self, request):
"""Log a user out.
:param request: the request object
This call removes information about the user's log in from the user
session. If a remember cookie exists, it is removed as well.
"""
session = self._get_session(request)
session.pop('_user_id', None)
session.pop('_fresh', None)
session.save()
request.g.current_user = None
if '_remember' in request.cookies:
self._update_remember_cookie(request, 0)
async def get_current_user(self, request):
"""Return the currently logged in user."""
if not hasattr(request.g, 'current_user'):
user_id = self._get_user_id_from_session(request)
if user_id:
request.g.current_user = await invoke_handler(
self.user_loader_callback, user_id)
else:
request.g.current_user = None
return request.g.current_user
def __call__(self, f):
"""Decorator to protect a route with authentication.
If the user is not logged in, Microdot will redirect to the login page
first. The decorated route will only run after successful login by the
user. If the user is already logged in, the route will run immediately.
Example::
login = Login()
@app.route('/secret')
@login
async def secret(request):
# only accessible to authenticated users
"""
async def wrapper(request, *args, **kwargs):
user = await self.get_current_user(request)
if not user:
return await self._redirect_to_login(request)
return await invoke_handler(f, request, *args, **kwargs)
return wrapper
def fresh(self, f):
"""Decorator to protect a route with "fresh" authentication.
This decorator prevents the route from running when the login session
is not fresh. A fresh session is a session that has been created from
direct user interaction with the login page, while a non-fresh session
occurs when a login is restored from a "remember me" cookie. Example::
login = Login()
@app.route('/secret')
@auth.fresh
async def secret(request):
# only accessible to authenticated users
# users logged in via remember me cookie will need to
# re-authenticate
"""
base_wrapper = self.__call__(f)
async def wrapper(request, *args, **kwargs):
session = self._get_session(request)
if session.get('_fresh'):
return await base_wrapper(request, *args, **kwargs)
return await self._redirect_to_login(request)
return wrapper

View File

@@ -7,9 +7,14 @@ servers for MicroPython and standard Python.
"""
import asyncio
import io
import json
import re
import time
try:
import orjson as json
except ImportError:
import json
try:
from inspect import iscoroutinefunction, iscoroutine
from functools import partial
@@ -56,23 +61,9 @@ MUTED_SOCKET_ERRORS = [
]
def urldecode_str(s):
s = s.replace('+', ' ')
parts = s.split('%')
if len(parts) == 1:
return s
result = [parts[0]]
for item in parts[1:]:
if item == '':
result.append('%')
else:
code = item[:2]
result.append(chr(int(code, 16)))
result.append(item[2:])
return ''.join(result)
def urldecode_bytes(s):
def urldecode(s):
if isinstance(s, str):
s = s.encode()
s = s.replace(b'+', b' ')
parts = s.split(b'%')
if len(parts) == 1:
@@ -329,15 +320,27 @@ class Request:
pass
def __init__(self, app, client_addr, method, url, http_version, headers,
body=None, stream=None, sock=None):
body=None, stream=None, sock=None, url_prefix='',
subapp=None, scheme=None, route=None):
#: The application instance to which this request belongs.
self.app = app
#: The address of the client, as a tuple (host, port).
self.client_addr = client_addr
#: The HTTP method of the request.
self.method = method
#: The request URL, including the path and query string.
#: The scheme of the request, either `http` or `https`.
self.scheme = scheme or 'http'
#: The request URL, including the path and query string, but not the
#: scheme or the host, which is available in the ``Host`` header.
self.url = url
#: The URL prefix, if the endpoint comes from a mounted
#: sub-application, or else ''.
self.url_prefix = url_prefix
#: The sub-application instance, or `None` if this isn't a mounted
#: endpoint.
self.subapp = subapp
#: The route function that handles this request.
self.route = route
#: The path portion of the URL.
self.path = url
#: The query string portion of the URL.
@@ -368,8 +371,8 @@ class Request:
self.content_type = self.headers['Content-Type']
if 'Cookie' in self.headers:
for cookie in self.headers['Cookie'].split(';'):
name, value = cookie.strip().split('=', 1)
self.cookies[name] = value
c = cookie.strip().split('=', 1)
self.cookies[c[0]] = c[1] if len(c) > 1 else ''
self._body = body
self.body_used = False
@@ -377,10 +380,12 @@ class Request:
self.sock = sock
self._json = None
self._form = None
self._files = None
self.after_request_handlers = []
@staticmethod
async def create(app, client_reader, client_writer, client_addr):
async def create(app, client_reader, client_writer, client_addr,
scheme=None):
"""Create a request object.
:param app: The Microdot application instance.
@@ -389,6 +394,7 @@ class Request:
:param client_writer: An output stream where the response data can be
written.
:param client_addr: The address of the client, as a tuple.
:param scheme: The scheme of the request, either 'http' or 'https'.
This method is a coroutine. It returns a newly created ``Request``
object.
@@ -425,7 +431,7 @@ class Request:
return Request(app, client_addr, method, url, http_version, headers,
body=body, stream=stream,
sock=(client_reader, client_writer))
sock=(client_reader, client_writer), scheme=scheme)
def _parse_urlencoded(self, urlencoded):
data = MultiDict()
@@ -433,12 +439,12 @@ class Request:
if isinstance(urlencoded, str):
for kv in [pair.split('=', 1)
for pair in urlencoded.split('&') if pair]:
data[urldecode_str(kv[0])] = urldecode_str(kv[1]) \
data[urldecode(kv[0])] = urldecode(kv[1]) \
if len(kv) > 1 else ''
elif isinstance(urlencoded, bytes): # pragma: no branch
for kv in [pair.split(b'=', 1)
for pair in urlencoded.split(b'&') if pair]:
data[urldecode_bytes(kv[0])] = urldecode_bytes(kv[1]) \
data[urldecode(kv[0])] = urldecode(kv[1]) \
if len(kv) > 1 else b''
return data
@@ -471,7 +477,13 @@ class Request:
def form(self):
"""The parsed form submission body, as a
:class:`MultiDict <microdot.MultiDict>` object, or ``None`` if the
request does not have a form submission."""
request does not have a form submission.
Forms that are URL encoded are processed by default. For multipart
forms to be processed, the
:func:`with_form_data <microdot.multipart.with_form_data>`
decorator must be added to the route.
"""
if self._form is None:
if self.content_type is None:
return None
@@ -481,6 +493,17 @@ class Request:
self._form = self._parse_urlencoded(self.body)
return self._form
@property
def files(self):
"""The files uploaded in the request as a dictionary, or ``None`` if
the request does not have any files.
The :func:`with_form_data <microdot.multipart.with_form_data>`
decorator must be added to the route that receives file uploads for
this property to be set.
"""
return self._files
def after_request(self, f):
"""Register a request-specific function to run after the request is
handled. Request-specific after request handlers run at the very end,
@@ -538,6 +561,7 @@ class Response:
'json': 'application/json',
'png': 'image/png',
'txt': 'text/plain',
'svg': 'image/svg+xml',
}
send_file_buffer_size = 1024
@@ -562,9 +586,9 @@ class Response:
self.headers = NoCaseDict(headers or {})
self.reason = reason
if isinstance(body, (dict, list)):
self.body = json.dumps(body).encode()
body = json.dumps(body)
self.headers['Content-Type'] = 'application/json; charset=UTF-8'
elif isinstance(body, str):
if isinstance(body, str):
self.body = body.encode()
else:
# this applies to bytes, file-like objects or generators
@@ -615,9 +639,13 @@ class Response:
"""Delete a cookie.
:param cookie: The cookie's name.
:param kwargs: Any cookie opens and flags supported by
``set_cookie()`` except ``expires`` and ``max_age``.
:param kwargs: Any cookie options and flags supported by
:meth:`set_cookie() <microdot.Response.set_cookie>`.
Values given for ``expires`` and ``max_age`` are
ignored.
"""
kwargs.pop('expires', None)
kwargs.pop('max_age', None)
self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT',
max_age=0, **kwargs)
@@ -746,7 +774,7 @@ class Response:
:param filename: The filename of the file.
:param status_code: The 3xx status code to use for the redirect. The
default is 302.
default is 200.
:param content_type: The ``Content-Type`` header to use in the
response. If omitted, it is generated
automatically from the file extension of the
@@ -798,13 +826,54 @@ class Response:
class URLPattern():
"""A class that represents the URL pattern for a route.
:param url_pattern: The route URL pattern, which can include static and
dynamic path segments. Dynamic segments are enclosed in
``<`` and ``>``. The type of the segment can be given
as a prefix, separated from the name with a colon.
Supported types are ``string`` (the default),
``int`` and ``path``. Custom types can be registered
using the :meth:`URLPattern.register_type` method.
"""
segment_patterns = {
'string': '/([^/]+)',
'int': '/(-?\\d+)',
'path': '/(.+)',
}
segment_parsers = {
'int': lambda value: int(value),
}
@classmethod
def register_type(cls, type_name, pattern='[^/]+', parser=None):
"""Register a new URL segment type.
:param type_name: The name of the segment type to register.
:param pattern: The regular expression pattern to use when matching
this segment type. If not given, a default matcher for
a single path segment is used.
:param parser: A callable that will be used to parse and transform the
value of the segment. If omitted, the value is returned
as a string.
"""
cls.segment_patterns[type_name] = '/({})'.format(pattern)
cls.segment_parsers[type_name] = parser
def __init__(self, url_pattern):
self.url_pattern = url_pattern
self.segments = []
self.regex = None
def compile(self):
"""Generate a regular expression for the URL pattern.
This method is automatically invoked the first time the URL pattern is
matched against a path.
"""
pattern = ''
use_regex = False
for segment in url_pattern.lstrip('/').split('/'):
for segment in self.url_pattern.lstrip('/').split('/'):
if segment and segment[0] == '<':
if segment[-1] != '>':
raise ValueError('invalid URL pattern')
@@ -815,81 +884,46 @@ class URLPattern():
type_ = 'string'
name = segment
parser = None
if type_ == 'string':
parser = self._string_segment
pattern += '/([^/]+)'
elif type_ == 'int':
parser = self._int_segment
pattern += '/(-?\\d+)'
elif type_ == 'path':
use_regex = True
pattern += '/(.+)'
elif type_.startswith('re:'):
use_regex = True
if type_.startswith('re:'):
pattern += '/({pattern})'.format(pattern=type_[3:])
else:
raise ValueError('invalid URL segment type')
if type_ not in self.segment_patterns:
raise ValueError('invalid URL segment type')
pattern += self.segment_patterns[type_]
parser = self.segment_parsers.get(type_)
self.segments.append({'parser': parser, 'name': name,
'type': type_})
else:
pattern += '/' + segment
self.segments.append({'parser': self._static_segment(segment)})
if use_regex:
import re
self.regex = re.compile('^' + pattern + '$')
self.segments.append({'parser': None})
self.regex = re.compile('^' + pattern + '$')
return self.regex
def match(self, path):
"""Match a path against the URL pattern.
Returns a dictionary with the values of all dynamic path segments if a
matche is found, or ``None`` if the path does not match this pattern.
"""
args = {}
if self.regex:
g = self.regex.match(path)
if not g:
return
i = 1
for segment in self.segments:
if 'name' not in segment:
continue
value = g.group(i)
if segment['type'] == 'int':
value = int(value)
args[segment['name']] = value
i += 1
else:
if len(path) == 0 or path[0] != '/':
return
path = path[1:]
args = {}
for segment in self.segments:
if path is None:
return
arg, path = segment['parser'](path)
g = (self.regex or self.compile()).match(path)
if not g:
return
i = 1
for segment in self.segments:
if 'name' not in segment:
continue
arg = g.group(i)
if segment['parser']:
arg = self.segment_parsers[segment['type']](arg)
if arg is None:
return
if 'name' in segment:
args[segment['name']] = arg
if path is not None:
return
args[segment['name']] = arg
i += 1
return args
def _static_segment(self, segment):
def _static(value):
s = value.split('/', 1)
if s[0] == segment:
return '', s[1] if len(s) > 1 else None
return None, None
return _static
def _string_segment(self, value):
s = value.split('/', 1)
if len(s[0]) == 0:
return None, None
return s[0], s[1] if len(s) > 1 else None
def _int_segment(self, value):
s = value.split('/', 1)
try:
return int(s[0]), s[1] if len(s) > 1 else None
except ValueError:
return None, None
def __repr__(self): # pragma: no cover
return 'URLPattern: {}'.format(self.url_pattern)
class HTTPException(Exception):
@@ -921,8 +955,8 @@ class Microdot:
self.after_request_handlers = []
self.after_error_request_handlers = []
self.error_handlers = {}
self.shutdown_requested = False
self.options_handler = self.default_options_handler
self.ssl = False
self.debug = False
self.server = None
@@ -959,7 +993,7 @@ class Microdot:
def decorated(f):
self.url_map.append(
([m.upper() for m in (methods or ['GET'])],
URLPattern(url_pattern), f))
URLPattern(url_pattern), f, '', None))
return f
return decorated
@@ -1127,24 +1161,33 @@ class Microdot:
return f
return decorated
def mount(self, subapp, url_prefix=''):
def mount(self, subapp, url_prefix='', local=False):
"""Mount a sub-application, optionally under the given URL prefix.
:param subapp: The sub-application to mount.
:param url_prefix: The URL prefix to mount the application under.
:param local: When set to ``True``, the before, after and error request
handlers only apply to endpoints defined in the
sub-application. When ``False``, they apply to the entire
application. The default is ``False``.
"""
for methods, pattern, handler in subapp.url_map:
for methods, pattern, handler, _prefix, _subapp in subapp.url_map:
self.url_map.append(
(methods, URLPattern(url_prefix + pattern.url_pattern),
handler))
for handler in subapp.before_request_handlers:
self.before_request_handlers.append(handler)
for handler in subapp.after_request_handlers:
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():
self.error_handlers[status_code] = handler
handler, url_prefix + _prefix, _subapp or subapp))
if not local:
for handler in subapp.before_request_handlers:
self.before_request_handlers.append(handler)
subapp.before_request_handlers = []
for handler in subapp.after_request_handlers:
self.after_request_handlers.append(handler)
subapp.after_request_handlers = []
for handler in subapp.after_error_request_handlers:
self.after_error_request_handlers.append(handler)
subapp.after_error_request_handlers = []
for status_code, handler in subapp.error_handlers.items():
self.error_handlers[status_code] = handler
subapp.error_handlers = {}
@staticmethod
def abort(status_code, reason=None):
@@ -1207,6 +1250,7 @@ class Microdot:
asyncio.run(main())
"""
self.ssl = ssl
self.debug = debug
async def serve(reader, writer):
@@ -1302,23 +1346,28 @@ class Microdot:
def find_route(self, req):
method = req.method.upper()
if method == 'OPTIONS' and self.options_handler:
return self.options_handler(req)
return self.options_handler(req), '', None
if method == 'HEAD':
method = 'GET'
f = 404
for route_methods, route_pattern, route_handler in self.url_map:
p = ''
s = None
for route_methods, route_pattern, route_handler, url_prefix, subapp \
in self.url_map:
req.url_args = route_pattern.match(req.path)
if req.url_args is not None:
p = url_prefix
s = subapp
if method in route_methods:
f = route_handler
break
else:
f = 405
return f
return f, p, s
def default_options_handler(self, req):
allow = []
for route_methods, route_pattern, route_handler in self.url_map:
for route_methods, route_pattern, _, _, _ in self.url_map:
if route_pattern.match(req.path) is not None:
allow.extend(route_methods)
if 'GET' in allow:
@@ -1331,13 +1380,18 @@ class Microdot:
try:
req = await Request.create(self, reader, writer,
writer.get_extra_info('peername'))
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS:
pass
else:
raise
except Exception as exc: # pragma: no cover
print_exception(exc)
res = await self.dispatch_request(req)
if res != Response.already_handled: # pragma: no branch
await res.write(writer)
try:
if res != Response.already_handled: # pragma: no branch
await res.write(writer)
await writer.aclose()
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS:
@@ -1349,43 +1403,78 @@ class Microdot:
method=req.method, path=req.path,
status_code=res.status_code))
def get_request_handlers(self, req, attr, local_first=True):
handlers = getattr(self, attr + '_handlers')
local_handlers = getattr(req.subapp, attr + '_handlers') \
if req and req.subapp else []
return local_handlers + handlers if local_first \
else handlers + local_handlers
async def error_response(self, req, status_code, reason=None):
if req and req.subapp and status_code in req.subapp.error_handlers:
return await invoke_handler(
req.subapp.error_handlers[status_code], req)
elif status_code in self.error_handlers:
return await invoke_handler(self.error_handlers[status_code], req)
return reason or 'N/A', status_code
async def dispatch_request(self, req):
after_request_handled = False
if req:
if req.content_length > req.max_content_length:
if 413 in self.error_handlers:
res = await invoke_handler(self.error_handlers[413], req)
else:
res = 'Payload too large', 413
# the request body is larger than allowed
res = await self.error_response(req, 413, 'Payload too large')
else:
f = self.find_route(req)
# find the route in the app's URL map
f, req.url_prefix, req.subapp = self.find_route(req)
try:
res = None
if callable(f):
for handler in self.before_request_handlers:
req.route = f
# invoke the before request handlers
for handler in self.get_request_handlers(
req, 'before_request', False):
res = await invoke_handler(handler, req)
if res:
break
# invoke the endpoint handler
if res is None:
res = await invoke_handler(
f, req, **req.url_args)
res = await invoke_handler(f, req, **req.url_args)
# process the response
if isinstance(res, int):
# an integer response is taken as a status code
# with an empty body
res = '', res
if isinstance(res, tuple):
# handle a tuple response
if isinstance(res[0], int):
# a tuple that starts with an int has an empty
# body
res = ('', res[0],
res[1] if len(res) > 1 else {})
body = res[0]
if isinstance(res[1], int):
# extract the status code and headers (if
# available)
status_code = res[1]
headers = res[2] if len(res) > 2 else {}
else:
# if the status code is missing, assume 200
status_code = 200
headers = res[1]
res = Response(body, status_code, headers)
elif not isinstance(res, Response):
# any other response types are wrapped in a
# Response object
res = Response(res)
for handler in self.after_request_handlers:
# invoke the after request handlers
for handler in self.get_request_handlers(
req, 'after_request', True):
res = await invoke_handler(
handler, req, res) or res
for handler in req.after_request_handlers:
@@ -1393,50 +1482,62 @@ class Microdot:
handler, req, res) or res
after_request_handled = True
elif isinstance(f, dict):
# the response from an OPTIONS request is a dict with
# headers
res = Response(headers=f)
elif f in self.error_handlers:
res = await invoke_handler(self.error_handlers[f], req)
else:
res = 'Not found', f
# if the route is not found, return a 404 or 405
# response as appropriate
res = await self.error_response(req, f, 'Not found')
except HTTPException as exc:
if exc.status_code in self.error_handlers:
res = self.error_handlers[exc.status_code](req)
else:
res = exc.reason, exc.status_code
# an HTTP exception was raised while handling this request
res = await self.error_response(req, exc.status_code,
exc.reason)
except Exception as exc:
# an unexpected exception was raised while handling this
# request
print_exception(exc)
exc_class = None
# invoke the error handler for the exception class if one
# exists
handler = None
res = None
if exc.__class__ in self.error_handlers:
exc_class = exc.__class__
if req.subapp and exc.__class__ in \
req.subapp.error_handlers:
handler = req.subapp.error_handlers[exc.__class__]
elif exc.__class__ in self.error_handlers:
handler = self.error_handlers[exc.__class__]
else:
# walk up the exception class hierarchy to try to find
# a handler
for c in mro(exc.__class__)[1:]:
if c in self.error_handlers:
exc_class = c
if req.subapp and c in req.subapp.error_handlers:
handler = req.subapp.error_handlers[c]
break
if exc_class:
elif c in self.error_handlers:
handler = self.error_handlers[c]
break
if handler:
try:
res = await invoke_handler(
self.error_handlers[exc_class], req, exc)
res = await invoke_handler(handler, req, exc)
except Exception as exc2: # pragma: no cover
print_exception(exc2)
if res is None:
if 500 in self.error_handlers:
res = await invoke_handler(
self.error_handlers[500], req)
else:
res = 'Internal server error', 500
# if there is still no response, issue a 500 error
res = await self.error_response(
req, 500, 'Internal server error')
else:
if 400 in self.error_handlers:
res = await invoke_handler(self.error_handlers[400], req)
else:
res = 'Bad request', 400
# if the request could not be parsed, issue a 400 error
res = await self.error_response(req, 400, 'Bad request')
if isinstance(res, tuple):
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
if not after_request_handled:
for handler in self.after_error_request_handlers:
# if the request did not finish due to an error, invoke the after
# error request handler
for handler in self.get_request_handlers(
req, 'after_error_request', True):
res = await invoke_handler(
handler, req, res) or res
res.is_head = (req and req.method == 'HEAD')

298
src/microdot/multipart.py Normal file
View File

@@ -0,0 +1,298 @@
import os
from random import choice
from microdot import abort, iscoroutine, AsyncBytesIO
from microdot.helpers import wraps
class FormDataIter:
"""Asynchronous iterator that parses a ``multipart/form-data`` body and
returns form fields and files as they are parsed.
:param request: the request object to parse.
Example usage::
from microdot.multipart import FormDataIter
@app.post('/upload')
async def upload(request):
async for name, value in FormDataIter(request):
print(name, value)
The iterator returns no values when the request has a content type other
than ``multipart/form-data``. For a file field, the returned value is of
type :class:`FileUpload`, which supports the
:meth:`read() <FileUpload.read>` and :meth:`save() <FileUpload.save>`
methods. Values for regular fields are provided as strings.
The request body is read efficiently in chunks of size
:attr:`buffer_size <FormDataIter.buffer_size>`. On iterations in which a
file field is encountered, the file must be consumed before moving on to
the next iteration, as the internal stream stored in ``FileUpload``
instances is invalidated at the end of the iteration.
"""
#: The size of the buffer used to read chunks of the request body. This
#: size must be large enough to hold at least one complete header or
#: boundary line, so it is not recommended to lower it, but it can be made
#: higher to improve performance at the expense of RAM.
buffer_size = 256
def __init__(self, request):
self.request = request
self.buffer = None
try:
mimetype, boundary = request.content_type.rsplit('; boundary=', 1)
except ValueError:
return # not a multipart request
if mimetype.split(';', 1)[0] == \
'multipart/form-data': # pragma: no branch
self.boundary = b'--' + boundary.encode()
self.extra_size = len(boundary) + 4
self.buffer = b''
def __aiter__(self):
return self
async def __anext__(self):
if self.buffer is None:
raise StopAsyncIteration
# make sure we have consumed the previous entry
while await self._read_buffer(self.buffer_size) != b'':
pass
# make sure we are at a boundary
await self._fill_buffer()
s = self.buffer.split(self.boundary, 1)
if len(s) != 2 or s[0] != b'':
abort(400) # pragma: no cover
self.buffer = s[1]
if self.buffer[:2] == b'--':
# we have reached the end
raise StopAsyncIteration
elif self.buffer[:2] != b'\r\n':
abort(400) # pragma: no cover
self.buffer = self.buffer[2:]
# parse the headers of this part
name = ''
filename = None
content_type = None
while True:
await self._fill_buffer()
lines = self.buffer.split(b'\r\n', 1)
if len(lines) != 2:
abort(400) # pragma: no cover
line, self.buffer = lines
if line == b'':
# we reached the end of the headers
break
header, value = line.decode().split(':', 1)
header = header.lower()
value = value.strip()
if header == 'content-disposition':
parts = value.split(';')
if len(parts) < 2 or parts[0] != 'form-data':
abort(400) # pragma: no cover
for part in parts[1:]:
part = part.strip()
if part.startswith('name="'):
name = part[6:-1]
elif part.startswith('filename="'): # pragma: no branch
filename = part[10:-1]
elif header == 'content-type': # pragma: no branch
content_type = value
if filename is None:
# this is a regular form field, so we read the value
value = b''
while True:
v = await self._read_buffer(self.buffer_size)
value += v
if len(v) < self.buffer_size: # pragma: no branch
break
return name, value.decode()
return name, FileUpload(filename, content_type, self._read_buffer)
async def _fill_buffer(self):
if self.buffer[-len(self.boundary) - 4:] == self.boundary + b'--\r\n':
# we have reached the end of the body
return
self.buffer += await self.request.stream.read(
self.buffer_size + self.extra_size - len(self.buffer))
async def _read_buffer(self, n=-1):
data = b''
while n == -1 or len(data) < n:
await self._fill_buffer()
s = self.buffer.split(self.boundary, 1)
data += s[0][:n] if n != -1 else s[0]
self.buffer = s[0][n:] if n != -1 else b''
if len(s) == 2: # pragma: no branch
# the end of this part is in the buffer
if len(self.buffer) < 2:
# we have read all the way to the end of this part
data = data[:-(2 - len(self.buffer))] # remove last "\r\n"
self.buffer += self.boundary + s[1]
return data
return data
class FileUpload:
"""Class that represents an uploaded file.
:param filename: the name of the uploaded file.
:param content_type: the content type of the uploaded file.
:param read: a coroutine that reads from the uploaded file's stream.
An uploaded file can be read from the stream using the :meth:`read()`
method or saved to a file using the :meth:`save()` method.
Instances of this class do not normally need to be created directly.
"""
#: The size at which the file is copied to a temporary file.
max_memory_size = 1024
def __init__(self, filename, content_type, read):
self.filename = filename
self.content_type = content_type
self._read = read
self._close = None
async def read(self, n=-1):
"""Read up to ``n`` bytes from the uploaded file's stream.
:param n: the maximum number of bytes to read. If ``n`` is -1 or not
given, the entire file is read.
"""
return await self._read(n)
async def save(self, path_or_file):
"""Save the uploaded file to the given path or file object.
:param path_or_file: the path to save the file to, or a file object
to which the file is to be written.
The file is read and written in chunks of size
:attr:`FormDataIter.buffer_size`.
"""
if isinstance(path_or_file, str):
f = open(path_or_file, 'wb')
else:
f = path_or_file
while True:
data = await self.read(FormDataIter.buffer_size)
if not data:
break
f.write(data)
if f != path_or_file:
f.close()
async def copy(self, max_memory_size=None):
"""Copy the uploaded file to a temporary file, to allow the parsing of
the multipart form to continue.
:param max_memory_size: the maximum size of the file to keep in memory.
If not given, then the class attribute of the
same name is used.
"""
max_memory_size = max_memory_size or FileUpload.max_memory_size
buffer = await self.read(max_memory_size)
if len(buffer) < max_memory_size:
f = AsyncBytesIO(buffer)
self._read = f.read
return self
# create a temporary file
while True:
tmpname = "".join([
choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
for _ in range(12)
])
try:
f = open(tmpname, 'x+b')
except OSError as e: # pragma: no cover
if e.errno == 17:
# EEXIST
continue
elif e.errno == 2:
# ENOENT
# some MicroPython platforms do not support mode "x"
f = open(tmpname, 'w+b')
if f.read(1) != b'':
f.close()
continue
else:
raise
break
f.write(buffer)
await self.save(f)
f.seek(0)
async def read(n=-1):
return f.read(n)
async def close():
f.close()
os.remove(tmpname)
self._read = read
self._close = close
return self
async def close(self):
"""Close an open file.
This method must be called to free memory or temporary files created by
the ``copy()`` method.
Note that when using the ``@with_form_data`` decorator this method is
called automatically when the request ends.
"""
if self._close:
await self._close()
self._close = None
def with_form_data(f):
"""Decorator that parses a ``multipart/form-data`` body and updates the
request object with the parsed form fields and files.
Example usage::
from microdot.multipart import with_form_data
@app.post('/upload')
@with_form_data
async def upload(request):
print('form fields:', request.form)
print('files:', request.files)
Note: this decorator calls the :meth:`FileUpload.copy()
<microdot.multipart.FileUpload.copy>` method on all uploaded files, so that
the request can be parsed in its entirety. The files are either copied to
memory or a temporary file, depending on their size. The temporary files
are automatically deleted when the request ends.
"""
@wraps(f)
async def wrapper(request, *args, **kwargs):
form = {}
files = {}
async for name, value in FormDataIter(request):
if isinstance(value, FileUpload):
files[name] = await value.copy()
else:
form[name] = value
if form or files:
request._form = form
request._files = files
try:
ret = f(request, *args, **kwargs)
if iscoroutine(ret):
ret = await ret
finally:
if request.files:
for file in request.files.values():
await file.close()
return ret
return wrapper

View File

@@ -23,9 +23,13 @@ class SessionDict(dict):
class Session:
"""
"""Session handling
:param app: The application instance.
:param key: The secret key, as a string or bytes object.
:param secret_key: The secret key, as a string or bytes object.
:param cookie_options: A dictionary with cookie options to pass as
arguments to :meth:`Response.set_cookie()
<microdot.Response.set_cookie>`.
"""
secret_key = None

View File

@@ -1,7 +1,11 @@
import asyncio
import json
from microdot.helpers import wraps
try:
import orjson as json
except ImportError:
import json
class SSE:
"""Server-Sent Events object.
@@ -13,7 +17,8 @@ class SSE:
self.event = asyncio.Event()
self.queue = []
async def send(self, data, event=None, event_id=None):
async def send(self, data, event=None, event_id=None, retry=None,
comment=False):
"""Send an event to the client.
:param data: the data to send. It can be given as a string, bytes, dict
@@ -23,18 +28,30 @@ class SSE:
given, it must be a string.
:param event_id: an optional event id, to send along with the data. If
given, it must be a string.
:param retry: an optional reconnection time (in seconds) that the
client should use when the connection is lost.
:param comment: when set to ``True``, the data is sent as a comment
line, and all other parameters are ignored. This is
useful as a heartbeat mechanism that keeps the
connection alive.
"""
if isinstance(data, (dict, list)):
data = json.dumps(data).encode()
elif isinstance(data, str):
data = json.dumps(data)
if isinstance(data, str):
data = data.encode()
elif not isinstance(data, bytes):
data = str(data).encode()
data = b'data: ' + data + b'\n\n'
if event_id:
data = b'id: ' + event_id.encode() + b'\n' + data
if event:
data = b'event: ' + event.encode() + b'\n' + data
if comment:
data = b': ' + data + b'\n\n'
else:
data = b'data: ' + data + b'\n\n'
if event_id:
data = b'id: ' + event_id.encode() + b'\n' + data
if event:
data = b'event: ' + event.encode() + b'\n' + data
if retry:
data = b'retry: ' + str(int(retry * 1000)).encode() + b'\n' + \
data
self.queue.append(data)
self.event.set()
@@ -57,7 +74,14 @@ def sse_response(request, event_function, *args, **kwargs):
sse = SSE()
async def sse_task_wrapper():
await event_function(request, sse, *args, **kwargs)
try:
await event_function(request, sse, *args, **kwargs)
except asyncio.CancelledError: # pragma: no cover
pass
except Exception as exc:
# the SSE task raised an exception so we need to pass it to the
# main route so that it is re-raised there
sse.queue.append(exc)
sse.event.set()
task = asyncio.create_task(sse_task_wrapper())
@@ -75,7 +99,11 @@ def sse_response(request, event_function, *args, **kwargs):
except IndexError:
await sse.event.wait()
sse.event.clear()
if event is None:
if isinstance(event, Exception):
# if the event is an exception we re-raise it here so that it
# can be handled appropriately
raise event
elif event is None:
raise StopAsyncIteration
return event

View File

@@ -1,4 +1,4 @@
import json
import asyncio
from microdot.microdot import Request, Response, AsyncBytesIO
try:
@@ -6,6 +6,11 @@ try:
except: # pragma: no cover # noqa: E722
WebSocket = None
try:
import orjson as json
except ImportError:
import json
__all__ = ['TestClient', 'TestResponse']
@@ -19,7 +24,7 @@ class TestResponse:
#: explicitly sets it on the response object.
self.reason = None
#: A dictionary with the response headers.
self.headers = None
self.headers = {}
#: The body of the response, as a bytes object.
self.body = None
#: The body of the response, decoded to a UTF-8 string. Set to
@@ -28,6 +33,11 @@ class TestResponse:
#: The body of the JSON response, decoded to a dictionary or list. Set
#: ``Note`` if the response does not have a JSON payload.
self.json = None
#: The body of the SSE response, decoded to a list of events, each
#: given as a dictionary with a ``data`` key and optionally also
#: ``event`` and ``id`` keys. Set to ``None`` if the response does not
#: have an SSE payload.
self.events = None
def _initialize_response(self, res):
self.status_code = res.status_code
@@ -37,10 +47,13 @@ class TestResponse:
async def _initialize_body(self, res):
self.body = b''
iter = res.body_iter()
async for body in iter: # pragma: no branch
if isinstance(body, str):
body = body.encode()
self.body += body
try:
async for body in iter: # pragma: no branch
if isinstance(body, str):
body = body.encode()
self.body += body
except asyncio.CancelledError: # pragma: no cover
pass
if hasattr(iter, 'aclose'): # pragma: no branch
await iter.aclose()
@@ -56,6 +69,36 @@ class TestResponse:
if content_type.split(';')[0] == 'application/json':
self.json = json.loads(self.text)
def _process_sse_body(self):
if 'Content-Type' in self.headers: # pragma: no branch
content_type = self.headers['Content-Type']
if content_type.split(';')[0] == 'text/event-stream':
self.events = []
for sse_event in self.body.split(b'\n\n'):
data = None
event = None
event_id = None
retry = None
for line in sse_event.split(b'\n'):
if line.startswith(b'data:'):
data = line[5:].strip()
elif line.startswith(b'event:'):
event = line[6:].strip().decode()
elif line.startswith(b'id:'):
event_id = line[3:].strip().decode()
elif line.startswith(b'retry:'):
retry = int(line[7:].strip()) / 1000
if data:
data_json = None
try:
data_json = json.loads(data)
except ValueError:
pass
self.events.append({
'data': data, 'data_json': data_json,
'event': event, 'event_id': event_id,
'retry': retry})
@classmethod
async def create(cls, res):
test_res = cls()
@@ -64,6 +107,7 @@ class TestResponse:
await test_res._initialize_body(res)
test_res._process_text_body()
test_res._process_json_body()
test_res._process_sse_body()
return test_res
@@ -73,6 +117,8 @@ class TestClient:
:param app: The Microdot application instance.
:param cookies: A dictionary of cookies to use when sending requests to the
application.
:param scheme: The scheme to use for requests, either 'http' or 'https'.
:param host: The host to use for requests.
The following example shows how to create a test client for an application
and send a test request::
@@ -93,23 +139,23 @@ class TestClient:
"""
__test__ = False # remove this class from pytest's test collection
def __init__(self, app, cookies=None):
def __init__(self, app, cookies=None, scheme=None, host=None):
self.app = app
self.cookies = cookies or {}
self.scheme = scheme
self.host = host or 'example.com:1234'
def _process_body(self, body, headers):
if body is None:
body = b''
elif isinstance(body, (dict, list)):
body = json.dumps(body).encode()
body = json.dumps(body)
if 'Content-Type' not in headers: # pragma: no cover
headers['Content-Type'] = 'application/json'
elif isinstance(body, str):
if isinstance(body, str):
body = body.encode()
if body and 'Content-Length' not in headers:
headers['Content-Length'] = str(len(body))
if 'Host' not in headers: # pragma: no branch
headers['Host'] = 'example.com:1234'
return body, headers
def _process_cookies(self, path, headers):
@@ -132,6 +178,8 @@ class TestClient:
def _render_request(self, method, path, headers, body):
request_bytes = '{method} {path} HTTP/1.0\n'.format(
method=method, path=path)
if 'Host' not in headers: # pragma: no branch
request_bytes += 'Host: {host}\n'.format(host=self.host)
for header, value in headers.items():
request_bytes += '{header}: {value}\n'.format(
header=header, value=value)
@@ -192,10 +240,10 @@ class TestClient:
writer = AsyncBytesIO(b'')
req = await Request.create(self.app, reader, writer,
('127.0.0.1', 1234))
('127.0.0.1', 1234), scheme=self.scheme)
res = await self.app.dispatch_request(req)
if res == Response.already_handled:
return None
return TestResponse()
res.complete()
self._update_cookies(res)

View File

@@ -149,18 +149,18 @@ class WebSocket:
raise WebSocketError('Websocket connection closed')
fin, opcode, has_mask, length = self._parse_frame_header(header)
if length == -2:
length = await self.request.sock[0].read(2)
length = await self.request.sock[0].readexactly(2)
length = int.from_bytes(length, 'big')
elif length == -8:
length = await self.request.sock[0].read(8)
length = await self.request.sock[0].readexactly(8)
length = int.from_bytes(length, 'big')
max_allowed_length = Request.max_body_length \
if self.max_message_length == -1 else self.max_message_length
if length > max_allowed_length:
raise WebSocketError('Message too large')
if has_mask: # pragma: no cover
mask = await self.request.sock[0].read(4)
payload = await self.request.sock[0].read(length)
mask = await self.request.sock[0].readexactly(4)
payload = await self.request.sock[0].readexactly(length)
if has_mask: # pragma: no cover
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
return opcode, payload

View File

@@ -96,7 +96,8 @@ class Microdot(BaseMicrodot):
headers,
body=body,
stream=stream,
sock=sock)
sock=sock,
scheme=environ.get('wsgi.url_scheme'))
req.environ = environ
res = self.loop.run_until_complete(self.dispatch_request(req))

View File

@@ -4,8 +4,12 @@ from tests.test_request import * # noqa: F401, F403
from tests.test_response import * # noqa: F401, F403
from tests.test_urlencode import * # noqa: F401, F403
from tests.test_url_pattern import * # noqa: F401, F403
from tests.test_multipart import * # noqa: F401, F403
from tests.test_websocket import * # noqa: F401, F403
from tests.test_sse import * # noqa: F401, F403
from tests.test_cors import * # noqa: F401, F403
from tests.test_utemplate import * # noqa: F401, F403
from tests.test_session import * # noqa: F401, F403
from tests.test_auth import * # noqa: F401, F403
from tests.test_login import * # noqa: F401, F403
from tests.test_csrf import * # noqa: F401, F403

View File

@@ -35,7 +35,7 @@ class TestASGI(unittest.TestCase):
class R:
def __init__(self):
self.i = 0
self.body = [b're', b'sp', b'on', b'se', b'']
self.body = [b're', b'sp', b'on', 'se', b'']
async def read(self, n):
data = self.body[self.i]
@@ -170,3 +170,103 @@ class TestASGI(unittest.TestCase):
self._run(app(scope, receive, send))
kill.assert_called()
def test_no_lifespan(self):
app = Microdot()
scope = {
'type': 'lifespan',
'asgi': {'version': '3.0'},
'state': {},
}
messages = [
{'type': 'lifespan.startup'},
{'type': 'lifespan.shutdown'},
]
message_iter = iter(messages)
sends = []
async def receive():
return next(message_iter)
async def send(packet):
sends.append(packet)
self._run(app(scope, receive, send))
self.assertEqual(sends, [
{'type': 'lifespan.startup.complete'},
{'type': 'lifespan.shutdown.complete'},
])
def test_lifespan(self):
async def startup(scope):
scope['state']['foo'] = 'bar'
async def shutdown(scope):
self.assertEqual(scope['state']['foo'], 'bar')
scope['state']['foo'] = 'baz'
app = Microdot(lifespan_startup=startup, lifespan_shutdown=shutdown)
scope = {
'type': 'lifespan',
'asgi': {'version': '3.0'},
'state': {},
}
messages = [
{'type': 'lifespan.startup'},
{'type': 'lifespan.shutdown'},
]
message_iter = iter(messages)
sends = []
async def receive():
return next(message_iter)
async def send(packet):
sends.append(packet)
self._run(app(scope, receive, send))
self.assertEqual(scope['state']['foo'], 'baz')
self.assertEqual(sends, [
{'type': 'lifespan.startup.complete'},
{'type': 'lifespan.shutdown.complete'},
])
def test_lifespan_errors(self):
async def startup(scope):
return scope['scope']['foo'] # KeyError
async def shutdown(scope):
return 1 / 0
app = Microdot(lifespan_startup=startup, lifespan_shutdown=shutdown)
scope = {
'type': 'lifespan',
'asgi': {'version': '3.0'},
'state': {},
}
messages = [
{'type': 'lifespan.startup'},
{'type': 'lifespan.shutdown'},
]
message_iter = iter(messages)
sends = []
async def receive():
return next(message_iter)
async def send(packet):
sends.append(packet)
self._run(app(scope, receive, send))
self.assertEqual(sends, [
{'type': 'lifespan.startup.failed',
'message': "KeyError('scope')"},
{'type': 'lifespan.shutdown.failed',
'message': "ZeroDivisionError('division by zero')"},
])

190
tests/test_auth.py Normal file
View File

@@ -0,0 +1,190 @@
import asyncio
import binascii
import unittest
from microdot import Microdot
from microdot.auth import BasicAuth, TokenAuth
from microdot.test_client import TestClient
class TestAuth(unittest.TestCase):
@classmethod
def setUpClass(cls):
if hasattr(asyncio, 'set_event_loop'):
asyncio.set_event_loop(asyncio.new_event_loop())
cls.loop = asyncio.get_event_loop()
def _run(self, coro):
return self.loop.run_until_complete(coro)
def test_basic_auth(self):
app = Microdot()
basic_auth = BasicAuth()
@basic_auth.authenticate
def authenticate(request, username, password):
if username == 'foo' and password == 'bar':
return {'username': username}
@app.route('/')
@basic_auth
def index(request):
return request.g.current_user['username']
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={
'Authorization': 'Basic ' + binascii.b2a_base64(
b'foo:bar').decode()}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'foo')
res = self._run(client.get('/', headers={
'Authorization': 'Basic ' + binascii.b2a_base64(
b'foo:baz').decode()}))
self.assertEqual(res.status_code, 401)
def test_basic_optional_auth(self):
app = Microdot()
basic_auth = BasicAuth()
@basic_auth.authenticate
def authenticate(request, username, password):
if username == 'foo' and password == 'bar':
return {'username': username}
@app.route('/')
@basic_auth.optional
def index(request):
return request.g.current_user['username'] \
if request.g.current_user else ''
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
res = self._run(client.get('/', headers={
'Authorization': 'Basic ' + binascii.b2a_base64(
b'foo:bar').decode()}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'foo')
res = self._run(client.get('/', headers={
'Authorization': 'Basic ' + binascii.b2a_base64(
b'foo:baz').decode()}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
def test_token_auth(self):
app = Microdot()
token_auth = TokenAuth()
@token_auth.authenticate
def authenticate(request, token):
if token == 'foo':
return 'user'
@app.route('/')
@token_auth
def index(request):
return request.g.current_user
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={
'Authorization': 'Basic foo'}))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={'Authorization': 'invalid'}))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={
'Authorization': 'Bearer foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'user')
def test_token_optional_auth(self):
app = Microdot()
token_auth = TokenAuth()
@token_auth.authenticate
def authenticate(request, token):
if token == 'foo':
return 'user'
@app.route('/')
@token_auth.optional
def index(request):
return request.g.current_user or ''
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
res = self._run(client.get('/', headers={
'Authorization': 'Basic foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
res = self._run(client.get('/', headers={'Authorization': 'foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
res = self._run(client.get('/', headers={
'Authorization': 'Bearer foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'user')
def test_token_auth_custom_header(self):
app = Microdot()
token_auth = TokenAuth(header='X-Auth-Token')
@token_auth.authenticate
def authenticate(request, token):
if token == 'foo':
return 'user'
@app.route('/')
@token_auth
def index(request):
return request.g.current_user
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={
'Authorization': 'Basic foo'}))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={'Authorization': 'foo'}))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={
'Authorization': 'Bearer foo'}))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={
'X-Token-Auth': 'Bearer foo'}))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={'X-Auth-Token': 'foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'user')
res = self._run(client.get('/', headers={'x-auth-token': 'foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'user')
@token_auth.errorhandler
def error_handler(request):
return {'status_code': 403}, 403
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 403)
self.assertEqual(res.json, {'status_code': 403})

310
tests/test_csrf.py Normal file
View File

@@ -0,0 +1,310 @@
import asyncio
import unittest
from microdot import Microdot
from microdot.cors import CORS
from microdot.csrf import CSRF
from microdot.test_client import TestClient
class TestCSRF(unittest.TestCase):
@classmethod
def setUpClass(cls):
if hasattr(asyncio, 'set_event_loop'):
asyncio.set_event_loop(asyncio.new_event_loop())
cls.loop = asyncio.get_event_loop()
def _run(self, coro):
return self.loop.run_until_complete(coro)
def test_protect_all_true(self):
app = Microdot()
csrf = CSRF(app)
@app.get('/')
def index(request):
return 204
@app.post('/submit')
def submit(request):
return 204
@app.post('/submit-exempt')
@csrf.exempt
def submit_exempt(request):
return 204
@app.get('/get-protected')
@csrf.protect
def get_protected(request):
return 204
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'https://evil.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post('/submit'))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'same-site'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'same-origin'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post('/submit-exempt'))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit-exempt', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.get('/get-protected'))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/get-protected', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 403)
def test_protect_all_false(self):
app = Microdot()
csrf = CSRF(protect_all=False)
csrf.initialize(app)
@app.get('/')
def index(request):
return 204
@app.post('/submit')
@csrf.protect
def submit(request):
return 204
@app.post('/submit-exempt')
def submit_exempt(request):
return 204
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'https://evil.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post('/submit'))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'same-site'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'same-origin'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post('/submit-exempt'))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit-exempt', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 204)
def test_allow_subdomains(self):
app = Microdot()
csrf = CSRF(allow_subdomains=True)
csrf.initialize(app)
@app.get('/')
def index(request):
return 204
@app.post('/submit')
def submit(request):
return 204
@app.post('/submit-exempt')
@csrf.exempt
def submit_exempt(request):
return 204
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'https://evil.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post('/submit'))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'same-site'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Sec-Fetch-Site': 'same-origin'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post('/submit-exempt'))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit-exempt', headers={'Sec-Fetch-Site': 'cross-site'}
))
self.assertEqual(res.status_code, 204)
def test_allowed_origins(self):
app = Microdot()
cors = CORS(allowed_origins=['http://foo.com', 'https://bar.com:8888'])
csrf = CSRF()
csrf.initialize(app, cors)
@app.get('/')
def index(request):
return 204
@app.post('/submit')
def submit(request):
return 204
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'http://foo.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'https://baz.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'http://x.baz.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post('/submit'))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Origin': 'https://bar.com:8888'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Origin': 'http://bar.com:8888'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={
'Sec-Fetch-Site': 'cross-site',
'Origin': 'https://bar.com:8888',
},
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={
'Sec-Fetch-Site': 'cross-site',
'Origin': 'https://bar.com:8889',
},
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={
'Sec-Fetch-Site': 'cross-site',
},
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Origin': 'https://x.y.bar.com:8888'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Origin': 'http://baz.com'}
))
self.assertEqual(res.status_code, 403)
def test_allowed_origins_with_subdomains(self):
app = Microdot()
cors = CORS(allowed_origins=['http://foo.com', 'https://bar.com:8888'])
csrf = CSRF(allow_subdomains=True)
csrf.initialize(app, cors)
@app.get('/')
def index(request):
return 204
@app.post('/submit')
def submit(request):
return 204
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'http://foo.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'https://baz.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.get(
'/', headers={'Origin': 'http://x.baz.com'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post('/submit'))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Origin': 'https://bar.com:8888'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Origin': 'http://bar.com:8888'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Origin': 'https://x.y.bar.com:8888'}
))
self.assertEqual(res.status_code, 204)
res = self._run(client.post(
'/submit', headers={'Origin': 'http://x.y.bar.com:8888'}
))
self.assertEqual(res.status_code, 403)
res = self._run(client.post(
'/submit', headers={'Origin': 'http://baz.com'}
))
self.assertEqual(res.status_code, 403)

190
tests/test_login.py Normal file
View File

@@ -0,0 +1,190 @@
import asyncio
import unittest
from microdot import Microdot
from microdot.login import Login
from microdot.session import Session
from microdot.test_client import TestClient
class TestLogin(unittest.TestCase):
@classmethod
def setUpClass(cls):
if hasattr(asyncio, 'set_event_loop'):
asyncio.set_event_loop(asyncio.new_event_loop())
cls.loop = asyncio.get_event_loop()
def _run(self, coro):
return self.loop.run_until_complete(coro)
def test_login(self):
app = Microdot()
Session(app, secret_key='secret')
login = Login()
class User:
def __init__(self, id, name):
self.id = id
self.name = name
@login.user_loader
def load_user(user_id):
return User(user_id, f'user{user_id}')
@app.get('/')
@login
async def index(request):
assert await login.get_current_user(request) == \
request.g.current_user
return request.g.current_user.name
@app.post('/login')
async def login_route(request):
return await login.login_user(request, User(123, 'user123'))
@app.post('/logout')
async def logout_route(request):
await login.logout_user(request)
return 'ok'
client = TestClient(app)
res = self._run(client.get('/?foo=bar'))
self.assertEqual(res.status_code, 302)
self.assertEqual(res.headers['Location'], '/login?next=/%3Ffoo%3Dbar')
res = self._run(client.post('/login?next=/%3Ffoo=bar'))
self.assertEqual(res.status_code, 302)
self.assertEqual(res.headers['Location'], '/?foo=bar')
self.assertEqual(len(res.headers['Set-Cookie']), 1)
self.assertIn('session', client.cookies)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'user123')
res = self._run(client.post('/logout'))
self.assertEqual(res.status_code, 200)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 302)
def test_login_bad_user_id(self):
class User:
def __init__(self, id, name):
self.id = id
self.name = name
app = Microdot()
Session(app, secret_key='secret')
login = Login()
@login.user_loader
def load_user(user_id):
return None
@app.get('/foo')
@login
async def index(request):
return 'ok'
@app.post('/login')
async def login_route(request):
return await login.login_user(request, User(1, 'user'))
client = TestClient(app)
res = self._run(client.post('/login?next=/'))
self.assertEqual(res.status_code, 302)
self.assertEqual(res.headers['Location'], '/')
res = self._run(client.get('/foo'))
self.assertEqual(res.status_code, 302)
self.assertEqual(res.headers['Location'], '/login?next=/foo')
def test_login_bad_redirect(self):
class User:
def __init__(self, id, name):
self.id = id
self.name = name
app = Microdot()
Session(app, secret_key='secret')
login = Login()
@login.user_loader
def load_user(user_id):
return user_id
@app.get('/')
@login
async def index(request):
return 'ok'
@app.post('/login')
async def login_route(request):
return await login.login_user(request, User(1, 'user'))
client = TestClient(app)
res = self._run(client.post('/login?next=http://example.com'))
self.assertEqual(res.status_code, 302)
self.assertEqual(res.headers['Location'], '/')
def test_login_remember(self):
class User:
def __init__(self, id, name):
self.id = id
self.name = name
app = Microdot()
Session(app, secret_key='secret')
login = Login()
@login.user_loader
def load_user(user_id):
return User(user_id, f'user{user_id}')
@app.get('/')
@login
def index(request):
return {'user': request.g.current_user.id}
@app.post('/login')
async def login_route(request):
return await login.login_user(request, User(1, 'user1'),
remember=True)
@app.post('/logout')
async def logout(request):
await login.logout_user(request)
return 'ok'
@app.get('/fresh')
@login.fresh
async def fresh(request):
return f'fresh {request.g.current_user.id}'
client = TestClient(app)
res = self._run(client.post('/login?next=/%3Ffoo=bar'))
self.assertEqual(res.status_code, 302)
self.assertEqual(res.headers['Location'], '/?foo=bar')
self.assertEqual(len(res.headers['Set-Cookie']), 2)
self.assertIn('session', client.cookies)
self.assertIn('_remember', client.cookies)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '{"user": 1}')
res = self._run(client.get('/fresh'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'fresh 1')
del client.cookies['session']
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
res = self._run(client.get('/fresh'))
self.assertEqual(res.status_code, 302)
self.assertEqual(res.headers['Location'], '/login?next=/fresh')
res = self._run(client.post('/logout'))
self.assertEqual(res.status_code, 200)
self.assertFalse('_remember' in client.cookies)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 302)

View File

@@ -180,6 +180,30 @@ class TestMicrodot(unittest.TestCase):
'text/plain; charset=UTF-8')
self.assertEqual(res.text, method)
def test_http_host(self):
app = Microdot()
@app.route('/')
def index(req):
return req.scheme + "://" + req.headers['Host']
client = TestClient(app, host='foo.com')
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'http://foo.com')
def test_https_host(self):
app = Microdot()
@app.route('/')
def index(req):
return req.scheme + "://" + req.headers['Host']
client = TestClient(app, scheme='https', host='foo.com')
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'https://foo.com')
def test_headers(self):
app = Microdot()
@@ -204,9 +228,10 @@ class TestMicrodot(unittest.TestCase):
res.set_cookie('four', '4')
res.delete_cookie('two', path='/')
res.delete_cookie('one', path='/bad')
res.delete_cookie('five', max_age=123, expires='foo')
return res
client = TestClient(app, cookies={'one': '1', 'two': '2'})
client = TestClient(app, cookies={'one': '1', 'two': '2', 'five': '5'})
res = self._run(client.get('/', headers={'Cookie': 'three=3'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
@@ -771,7 +796,7 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res, None)
self.assertEqual(res.body, None)
def test_mount(self):
subapp = Microdot()
@@ -794,7 +819,7 @@ class TestMicrodot(unittest.TestCase):
@subapp.route('/app')
def index(req):
return req.g.before + ':foo'
return req.g.before + ':' + req.url_prefix
app = Microdot()
app.mount(subapp, url_prefix='/sub')
@@ -811,4 +836,203 @@ class TestMicrodot(unittest.TestCase):
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'before:foo:after')
self.assertEqual(res.text, 'before:/sub:after')
def test_mount_local(self):
subapp1 = Microdot()
subapp2 = Microdot()
@subapp1.before_request
def before1(req):
req.g.before += ':before1'
@subapp1.after_error_request
def after_error1(req, res):
res.body += b':errorafter'
@subapp1.errorhandler(ValueError)
def value_error(req, exc):
return str(exc), 400
@subapp1.route('/')
def index1(req):
raise ZeroDivisionError()
@subapp1.route('/foo')
def foo(req):
return req.g.before + ':foo:' + req.url_prefix
@subapp1.route('/err')
def err(req):
raise ValueError('err')
@subapp1.route('/err2')
def err2(req):
class MyErr(ValueError):
pass
raise MyErr('err')
@subapp2.before_request
def before2(req):
req.g.before += ':before2'
@subapp2.after_request
def after2(req, res):
res.body += b':after'
@subapp2.errorhandler(405)
def method_not_found2(req):
return '405', 405
@subapp2.route('/bar')
def bar(req):
return req.g.before + ':bar:' + req.url_prefix
@subapp2.route('/baz')
def baz(req):
abort(405)
app = Microdot()
@app.before_request
def before(req):
req.g.before = 'before-app'
@app.after_request
def after(req, res):
res.body += b':after-app'
app.mount(subapp1, local=True)
app.mount(subapp2, url_prefix='/sub', local=True)
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 500)
self.assertEqual(res.text, 'Internal server error:errorafter')
res = self._run(client.get('/foo'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'before-app:before1:foo::after-app')
res = self._run(client.get('/err'))
self.assertEqual(res.status_code, 400)
self.assertEqual(res.text, 'err:errorafter')
res = self._run(client.get('/err2'))
self.assertEqual(res.status_code, 400)
self.assertEqual(res.text, 'err:errorafter')
res = self._run(client.get('/sub/bar'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text,
'before-app:before2:bar:/sub:after:after-app')
res = self._run(client.post('/sub/bar'))
self.assertEqual(res.status_code, 405)
self.assertEqual(res.text, '405')
res = self._run(client.get('/sub/baz'))
self.assertEqual(res.status_code, 405)
self.assertEqual(res.text, '405')
def test_many_mounts(self):
subsubapp = Microdot()
@subsubapp.before_request
def subsubapp_before(req):
req.g.before = 'subsubapp'
@subsubapp.route('/')
def subsubapp_index(req):
return f'{req.g.before}:{req.subapp == subsubapp}:{req.url_prefix}'
subapp = Microdot()
@subapp.before_request
def subapp_before(req):
req.g.before = 'subapp'
@subapp.route('/')
def subapp_index(req):
return f'{req.g.before}:{req.subapp == subapp}:{req.url_prefix}'
app = Microdot()
@app.before_request
def app_before(req):
req.g.before = 'app'
@app.route('/')
def app_index(req):
return f'{req.g.before}:{req.subapp is None}:{req.url_prefix}'
subapp.mount(subsubapp, url_prefix='/subsub')
app.mount(subapp, url_prefix='/sub')
client = TestClient(app)
res = self._run(client.get('/sub/subsub/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'subsubapp:True:/sub/subsub')
res = self._run(client.get('/sub/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'subsubapp:True:/sub')
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'subsubapp:True:')
def test_many_local_mounts(self):
subsubapp = Microdot()
@subsubapp.before_request
def subsubapp_before(req):
req.g.before = 'subsubapp'
@subsubapp.route('/')
def subsubapp_index(req):
return f'{req.g.before}:{req.subapp == subsubapp}:{req.url_prefix}'
subapp = Microdot()
@subapp.before_request
def subapp_before(req):
req.g.before = 'subapp'
@subapp.route('/')
def subapp_index(req):
return f'{req.g.before}:{req.subapp == subapp}:{req.url_prefix}'
app = Microdot()
@app.before_request
def app_before(req):
req.g.before = 'app'
@app.route('/')
def app_index(req):
return f'{req.g.before}:{req.subapp is None}:{req.url_prefix}'
subapp.mount(subsubapp, url_prefix='/subsub', local=True)
app.mount(subapp, url_prefix='/sub', local=True)
client = TestClient(app)
res = self._run(client.get('/sub/subsub/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'subsubapp:True:/sub/subsub')
res = self._run(client.get('/sub/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'subapp:True:/sub')
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'app:True:')

223
tests/test_multipart.py Normal file
View File

@@ -0,0 +1,223 @@
import asyncio
import os
import unittest
from microdot import Microdot
from microdot.multipart import with_form_data, FileUpload, FormDataIter
from microdot.test_client import TestClient
class TestMultipart(unittest.TestCase):
@classmethod
def setUpClass(cls):
if hasattr(asyncio, 'set_event_loop'):
asyncio.set_event_loop(asyncio.new_event_loop())
cls.loop = asyncio.get_event_loop()
def _run(self, coro):
return self.loop.run_until_complete(coro)
def test_simple_form(self):
app = Microdot()
@app.post('/sync')
@with_form_data
def sync_route(req):
return dict(req.form)
@app.post('/async')
@with_form_data
async def async_route(req):
return dict(req.form)
client = TestClient(app)
res = self._run(client.post(
'/sync', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="baz"\r\n\r\nbaz\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {'foo': 'bar', 'baz': 'baz'})
res = self._run(client.post(
'/async', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="baz"\r\n\r\nbaz\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {'foo': 'bar', 'baz': 'baz'})
def test_form_with_files(self):
saved_max_memory_size = FileUpload.max_memory_size
FileUpload.max_memory_size = 5
app = Microdot()
@app.post('/async')
@with_form_data
async def async_route(req):
d = dict(req.form)
for name, file in req.files.items():
d[name] = '{}|{}|{}'.format(file.filename, file.content_type,
(await file.read()).decode())
return d
client = TestClient(app)
res = self._run(client.post(
'/async', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="f"; filename="f"\r\n'
b'Content-Type: text/plain\r\n\r\nbaz\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="g"; filename="g"\r\n'
b'Content-Type: text/html\r\n\r\n<p>hello</p>\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="x"\r\n\r\ny\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {'foo': 'bar', 'x': 'y',
'f': 'f|text/plain|baz',
'g': 'g|text/html|<p>hello</p>'})
FileUpload.max_memory_size = saved_max_memory_size
def test_large_file_upload(self):
saved_buffer_size = FormDataIter.buffer_size
FormDataIter.buffer_size = 100
saved_max_memory_size = FileUpload.max_memory_size
FileUpload.max_memory_size = 200
app = Microdot()
@app.post('/')
@with_form_data
async def index(req):
return {"len": len(await req.files['f'].read())}
client = TestClient(app)
res = self._run(client.post(
'/', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="f"; filename="f"\r\n'
b'Content-Type: text/plain\r\n\r\n' + b'*' * 398 + b'\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {'len': 398})
FormDataIter.buffer_size = saved_buffer_size
FileUpload.max_memory_size = saved_max_memory_size
def test_file_save(self):
app = Microdot()
@app.post('/async')
@with_form_data
async def async_route(req):
for _, file in req.files.items():
await file.save('_x.txt')
client = TestClient(app)
res = self._run(client.post(
'/async', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="f"; filename="f"\r\n'
b'Content-Type: text/plain\r\n\r\nbaz\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 204)
with open('_x.txt', 'rb') as f:
self.assertEqual(f.read(), b'baz')
os.unlink('_x.txt')
def test_no_form(self):
app = Microdot()
@app.post('/async')
@with_form_data
async def async_route(req):
return str(req.form)
client = TestClient(app)
res = self._run(client.post('/async', body={'foo': 'bar'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'None')
def test_upload_iterator(self):
app = Microdot()
@app.post('/async')
async def async_route(req):
d = {}
async for name, value in FormDataIter(req):
if isinstance(value, FileUpload):
d[name] = '{}|{}|{}'.format(value.filename,
value.content_type,
(await value.read(4)).decode())
else:
d[name] = value
return d
client = TestClient(app)
res = self._run(client.post(
'/async', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="f"; filename="f"\r\n'
b'Content-Type: text/plain\r\n\r\nbaz\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="g"; filename="g.h"\r\n'
b'Content-Type: text/html\r\n\r\n<p>hello</p>\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="x"\r\n\r\ny\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="h"; filename="hh"\r\n'
b'Content-Type: text/plain\r\n\r\nyy' + (b'z' * 500) + b'\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="i"; filename="i.1"\r\n'
b'Content-Type: text/plain\r\n\r\n1234\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {
'foo': 'bar',
'f': 'f|text/plain|baz',
'g': 'g.h|text/html|<p>h',
'x': 'y',
'h': 'hh|text/plain|yyzz',
'i': 'i.1|text/plain|1234',
})

View File

@@ -35,16 +35,17 @@ class TestRequest(unittest.TestCase):
def test_headers(self):
fd = get_async_request_fd('GET', '/foo', headers={
'Content-Type': 'application/json',
'Cookie': 'foo=bar;abc=def',
'Cookie': 'foo=bar;nothing;abc=def;',
'Content-Length': '3'}, body='aaa')
req = self._run(Request.create('app', fd, 'writer', 'addr'))
self.assertEqual(req.headers, {
'Host': 'example.com:1234',
'Content-Type': 'application/json',
'Cookie': 'foo=bar;abc=def',
'Cookie': 'foo=bar;nothing;abc=def;',
'Content-Length': '3'})
self.assertEqual(req.content_type, 'application/json')
self.assertEqual(req.cookies, {'foo': 'bar', 'abc': 'def'})
self.assertEqual(req.cookies, {'foo': 'bar', 'nothing': '',
'abc': 'def', '': ''})
self.assertEqual(req.content_length, 3)
self.assertEqual(req.body, b'aaa')

Some files were not shown because too many files have changed in this diff Show More