34 Commits
v2.0.4 ... main

Author SHA1 Message Date
Miguel Grinberg
d864b81b65 revert to default funding file #nolog 2025-01-06 17:49:09 +00:00
Miguel Grinberg
d7459f23b2 Version 2.0.8.dev0 2024-11-10 22:57:45 +00:00
Miguel Grinberg
32f5e415e7 Release 2.0.7 2024-11-10 22:57:31 +00:00
Miguel Grinberg
c46e429106 Accept responses with just a status code (Fixes #263) 2024-11-10 20:09:05 +00:00
Miguel Grinberg
4eac013087 Accept responses with just a status code (Fixes #263) 2024-11-10 00:35:21 +00:00
dependabot[bot]
496a288064 Bump werkzeug from 3.0.3 to 3.0.6 in /examples/benchmark (#260) #nolog
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.0.3 to 3.0.6.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/3.0.3...3.0.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-26 11:37:08 +01:00
dependabot[bot]
bcd876fcae Bump quart from 0.19.4 to 0.19.7 in /examples/benchmark (#259) #nolog
Bumps [quart](https://github.com/pallets/quart) from 0.19.4 to 0.19.7.
- [Release notes](https://github.com/pallets/quart/releases)
- [Changelog](https://github.com/pallets/quart/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/quart/compare/0.19.4...0.19.7)

---
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>
2024-10-26 00:57:50 +01:00
Stanislav Garanzha
5e5fc5e93e Fix urls in docs (#253)
* Fix 404 external links in intro.rst

* Fix broken links in extensions.rst

`examples/cors/cors.py` does not exist

`uvicorn.org` - failed to resolve
2024-08-17 18:41:43 +01:00
Miguel Grinberg
8895af3737 add tox to dev dependencies 2024-08-15 20:40:54 +01:00
Miguel Grinberg
0a021462e0 Better documentation for start_server() method (Fixes #252) 2024-08-15 19:10:34 +01:00
Lukas Kremla
482ab6d5ca Fixed gzip automatic content-type assignment and added automatic compression header configuration (#251)
* Fixed gzip automatic content-type assignment and added automatic compression setting

This implements the fix for detecting the proper content-type even when the file has the ".gz" extension. It further makes sure the compression headers are set properly if a "gz." file is detected, but the compression headers weren't explicitly set by the user.

* Added a test for properly auto-determining mime types and setting content encoding header

* Modified the gzip file header assignments and following tests according to the feedback.

---------

Co-authored-by: Lukáš Kremla <lukas.kremla@bonnel.cz>
2024-08-14 23:02:23 +01:00
dependabot[bot]
5fe06f6bd5 Bump certifi from 2023.11.17 to 2024.7.4 in /examples/benchmark (#244)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.11.17 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2023.11.17...2024.07.04)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-06 11:17:41 +01:00
dependabot[bot]
c170e840ec Bump urllib3 from 2.1.0 to 2.2.2 in /examples/benchmark (#241) #nolog
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.1.0 to 2.2.2.
- [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.1.0...2.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-19 00:12:28 +01:00
Miguel Grinberg
3a39b47ea8 Version 2.0.7.dev0 2024-06-18 23:14:36 +01:00
Miguel Grinberg
53287217ae Release 2.0.6 2024-06-18 23:14:14 +01:00
Miguel Grinberg
6ffb8a8fe9 Cookie path support in session and test client 2024-06-18 20:56:18 +01:00
Miguel Grinberg
0151611fc8 Configurable session cookie options (Fixes #242) 2024-06-18 00:09:44 +01:00
dependabot[bot]
4204db61e5 Bump jinja2 from 3.1.3 to 3.1.4 in /examples/benchmark (#230) #nolog
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.3 to 3.1.4.
- [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.3...3.1.4)

---
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>
2024-05-21 11:40:13 +01:00
dependabot[bot]
12438743a8 Bump werkzeug from 3.0.1 to 3.0.3 in /examples/benchmark (#229) #nolog
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.0.1 to 3.0.3.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/3.0.1...3.0.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 11:39:51 +01:00
dependabot[bot]
7cbb1edf59 Bump requests from 2.31.0 to 2.32.0 in /examples/benchmark (#232) #nolog
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 11:39:16 +01:00
Miguel Grinberg
dac6df7a7a use codecov token for coverage uploads #nolog 2024-04-28 00:31:53 +01:00
dependabot[bot]
5d6e838f3c Bump gunicorn from 21.2.0 to 22.0.0 in /examples/benchmark (#224) #nolog
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.2.0 to 22.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/21.2.0...22.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>
2024-04-17 23:52:37 +01:00
dependabot[bot]
563bfdc8f5 Bump idna from 3.6 to 3.7 in /examples/benchmark (#223) #nolog
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-12 15:16:52 +01:00
Miguel Grinberg
679d8e63b8 Fix docs build #nolog 2024-03-24 19:56:43 +00:00
Miguel Grinberg
4cb155ee41 Improved cookie support in the test client 2024-03-24 19:45:22 +00:00
Miguel Grinberg
dea79c5ce2 Make Session class more reusable 2024-03-23 16:29:36 +00:00
Carlo Colombo
6b1fd61917 removed outdated import from documentation (Fixes #216) 2024-03-15 11:03:10 +00:00
Miguel Grinberg
f6876c0d15 Use @wraps on decorated functions 2024-03-14 00:16:35 +00:00
Hamsanger
904d5fcaa2 Add event ID to the SSE implementation (#213) 2024-03-10 23:52:43 +00:00
Miguel Grinberg
a0ea439def Add roadmap details to readme 2024-03-10 17:15:14 +00:00
Miguel Grinberg
a1801d9a53 Version 2.0.6.dev0 2024-03-09 10:48:18 +00:00
Miguel Grinberg
14f2c9d345 Release 2.0.5 2024-03-09 10:48:09 +00:00
Miguel Grinberg
d0a4cf8fa7 Handle 0 as an integer argument (Fixes #212) 2024-03-06 20:34:00 +00:00
Miguel Grinberg
901f4e55b8 Version 2.0.5.dev0 2024-02-20 23:15:01 +00:00
21 changed files with 315 additions and 68 deletions

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
github: miguelgrinberg
patreon: miguelgrinberg
custom: https://paypal.me/miguelgrinberg

View File

@@ -64,6 +64,7 @@ jobs:
with: with:
files: ./coverage.xml files: ./coverage.xml
fail_ci_if_error: true fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
benchmark: benchmark:
name: benchmark name: benchmark
runs-on: ubuntu-latest runs-on: ubuntu-latest

4
.gitignore vendored
View File

@@ -25,6 +25,8 @@ wheels/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
requirements.txt
requirements-dev.txt
# PyInstaller # PyInstaller
# Usually these files are written by a python script from a template # Usually these files are written by a python script from a template
@@ -90,6 +92,8 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
.direnv
.envrc
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject

View File

@@ -1,5 +1,27 @@
# Microdot change log # Microdot change log
**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))
- Fixed compressed file content-type assignment [#251](https://github.com/miguelgrinberg/microdot/issues/251) ([commit](https://github.com/miguelgrinberg/microdot/commit/482ab6d5ca068d71ea6301f45918946161e9fcc1)) (thanks **Lukas Kremla**!)
- Better documentation for start_server[#252](https://github.com/miguelgrinberg/microdot/issues/252) ([commit](https://github.com/miguelgrinberg/microdot/commit/0a021462e0c42c249d587a2d600f5a21a408adfc))
- Fix URLs in documentation [#253](https://github.com/miguelgrinberg/microdot/issues/253) ([commit](https://github.com/miguelgrinberg/microdot/commit/5e5fc5e93e11cbf6e3dc8036494e8732d1815d3e)) (thanks **Stanislav Garanzha**!)
**Release 2.0.6** - 2024-06-18
- Add event ID to the SSE implementation [#213](https://github.com/miguelgrinberg/microdot/issues/213) ([commit](https://github.com/miguelgrinberg/microdot/commit/904d5fcaa2d19d939a719b8e68c4dee3eb470739)) (thanks **Hamsanger**!)
- Configurable session cookie options [#242](https://github.com/miguelgrinberg/microdot/issues/242) ([commit](https://github.com/miguelgrinberg/microdot/commit/0151611fc84fec450820d673f4c4d70c32c990a7))
- Improved cookie support in the test client ([commit](https://github.com/miguelgrinberg/microdot/commit/4cb155ee411dc2d9c9f15714cb32b25ba79b156a))
- Cookie path support in session extension and test client ([commit](https://github.com/miguelgrinberg/microdot/commit/6ffb8a8fe920111c4d8c16e98715a0d5ee2d1da3))
- Refactor `Session` class to make it more reusable ([commit](https://github.com/miguelgrinberg/microdot/commit/dea79c5ce224dec7858ffef45a42bed442fd3a5a))
- Use `@functools.wraps` on decorated functions ([commit](https://github.com/miguelgrinberg/microdot/commit/f6876c0d154adcae96098405fb6a1fdf1ea4ec28))
- Removed outdated import from documentation [#216](https://github.com/miguelgrinberg/microdot/issues/216) ([commit](https://github.com/miguelgrinberg/microdot/commit/6b1fd6191702e7a9ad934fddfcdd0a3cebea7c94)) (thanks **Carlo Colombo**!)
- Add roadmap details to readme ([commit](https://github.com/miguelgrinberg/microdot/commit/a0ea439def238084c4d68309c0992b66ffd28ad6))
**Release 2.0.5** - 2024-03-09
- Correct handling of 0 as an integer argument (regression from #207) [#212](https://github.com/miguelgrinberg/microdot/issues/212) ([commit](https://github.com/miguelgrinberg/microdot/commit/d0a4cf8fa7dfb1da7466157b18d3329a8cf9a5df))
**Release 2.0.4** - 2024-02-20 **Release 2.0.4** - 2024-02-20
- Do not use regexes for parsing simple URLs [#207](https://github.com/miguelgrinberg/microdot/issues/207) ([commit #1](https://github.com/miguelgrinberg/microdot/commit/38262c56d34784401659639b482a4a1224e1e59a) [commit #2](https://github.com/miguelgrinberg/microdot/commit/f6cba2c0f7e18e2f32b5adb779fb037b6c473eab)) - Do not use regexes for parsing simple URLs [#207](https://github.com/miguelgrinberg/microdot/issues/207) ([commit #1](https://github.com/miguelgrinberg/microdot/commit/38262c56d34784401659639b482a4a1224e1e59a) [commit #2](https://github.com/miguelgrinberg/microdot/commit/f6cba2c0f7e18e2f32b5adb779fb037b6c473eab))

View File

@@ -32,10 +32,25 @@ describes the backwards incompatible changes that were made.
## Resources ## Resources
- Documentation
- [Stable](https://microdot.readthedocs.io/en/stable/)
- [Latest](https://microdot.readthedocs.io/en/latest/)
- Still using version 1?
- [Code](https://github.com/miguelgrinberg/microdot/tree/v1)
- [Documentation](https://microdot.readthedocs.io/en/v1/)
- [Change Log](https://github.com/miguelgrinberg/microdot/blob/main/CHANGES.md) - [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))
## 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
- 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)!

View File

@@ -286,7 +286,7 @@ Cross-Origin Resource Sharing (CORS)
- | None - | None
* - Examples * - Examples
- | `cors.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/cors/cors.py>`_ - | `app.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/cors/app.py>`_
The CORS extension provides support for `Cross-Origin Resource Sharing The CORS extension provides support for `Cross-Origin Resource Sharing
(CORS) <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_. CORS is a (CORS) <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_. CORS is a
@@ -363,7 +363,7 @@ Using an ASGI Web Server
- | `asgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/asgi.py>`_ - | `asgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/asgi.py>`_
* - Required external dependencies * - Required external dependencies
- | An ASGI web server, such as `Uvicorn <https://uvicorn.org/>`_. - | An ASGI web server, such as `Uvicorn <https://www.uvicorn.org/>`_.
* - Examples * - Examples
- | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_asgi.py>`_ - | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_asgi.py>`_

View File

@@ -82,8 +82,34 @@ handler functions can be defined as ``async def`` or ``def`` functions, but
``async def`` functions are recommended for performance. ``async def`` functions are recommended for performance.
The :func:`run() <microdot.Microdot.run>` method starts the application's web The :func:`run() <microdot.Microdot.run>` method starts the application's web
server on port 5000 by default. This method blocks while it waits for server on port 5000 by default, and creates its own asynchronous loop. This
connections from clients. 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 Running with CPython
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
@@ -92,7 +118,7 @@ Running with CPython
:align: left :align: left
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
* - Required external dependencies * - Required external dependencies
- | None - | None
@@ -118,7 +144,7 @@ Running with MicroPython
:align: left :align: left
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
* - Required external dependencies * - Required external dependencies
- | None - | None
@@ -145,8 +171,9 @@ changed by passing the ``port`` argument to the ``run()`` method.
Web Server Configuration Web Server Configuration
^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
The :func:`run() <microdot.Microdot.run>` method supports a few arguments to The :func:`run() <microdot.Microdot.run>` and
configure the web server. :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 - ``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:: argument to use a port different than the default of 5000. For example::

View File

@@ -16,7 +16,7 @@ blinker==1.7.0
# quart # quart
build==1.0.3 build==1.0.3
# via pip-tools # via pip-tools
certifi==2023.11.17 certifi==2024.7.4
# via requests # via requests
charset-normalizer==3.3.2 charset-normalizer==3.3.2
# via requests # via requests
@@ -32,7 +32,7 @@ flask==3.0.0
# via # via
# -r requirements.in # -r requirements.in
# quart # quart
gunicorn==21.2.0 gunicorn==22.0.0
# via -r requirements.in # via -r requirements.in
h11==0.14.0 h11==0.14.0
# via # via
@@ -49,7 +49,7 @@ hypercorn==0.15.0
# via quart # via quart
hyperframe==6.0.1 hyperframe==6.0.1
# via h2 # via h2
idna==3.6 idna==3.7
# via # via
# anyio # anyio
# requests # requests
@@ -57,7 +57,7 @@ itsdangerous==2.1.2
# via # via
# flask # flask
# quart # quart
jinja2==3.1.3 jinja2==3.1.4
# via # via
# flask # flask
# quart # quart
@@ -82,9 +82,9 @@ pydantic-core==2.14.5
# via pydantic # via pydantic
pyproject-hooks==1.0.0 pyproject-hooks==1.0.0
# via build # via build
quart==0.19.4 quart==0.19.7
# via -r requirements.in # via -r requirements.in
requests==2.31.0 requests==2.32.0
# via -r requirements.in # via -r requirements.in
sniffio==1.3.0 sniffio==1.3.0
# via anyio # via anyio
@@ -95,11 +95,11 @@ typing-extensions==4.9.0
# fastapi # fastapi
# pydantic # pydantic
# pydantic-core # pydantic-core
urllib3==2.1.0 urllib3==2.2.2
# via requests # via requests
uvicorn==0.24.0.post1 uvicorn==0.24.0.post1
# via -r requirements.in # via -r requirements.in
werkzeug==3.0.1 werkzeug==3.0.6
# via # via
# flask # flask
# quart # quart

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "microdot" name = "microdot"
version = "2.0.4" version = "2.0.8.dev0"
authors = [ authors = [
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" }, { name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
] ]
@@ -14,6 +14,8 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
requires-python = ">=3.8" requires-python = ">=3.8"
dependencies = [
]
[project.readme] [project.readme]
file = "README.md" file = "README.md"
@@ -24,8 +26,12 @@ Homepage = "https://github.com/miguelgrinberg/microdot"
"Bug Tracker" = "https://github.com/miguelgrinberg/microdot/issues" "Bug Tracker" = "https://github.com/miguelgrinberg/microdot/issues"
[project.optional-dependencies] [project.optional-dependencies]
dev = [
"tox",
]
docs = [ docs = [
"sphinx", "sphinx",
"pyjwt",
] ]
[tool.setuptools] [tool.setuptools]

8
src/microdot/helpers.py Normal file
View File

@@ -0,0 +1,8 @@
try:
from functools import wraps
except ImportError: # pragma: no cover
# MicroPython does not currently implement functools.wraps
def wraps(wrapped):
def _(wrapper):
return wrapper
return _

View File

@@ -598,7 +598,7 @@ class Response:
else: # pragma: no cover else: # pragma: no cover
http_cookie += '; Expires=' + time.strftime( http_cookie += '; Expires=' + time.strftime(
'%a, %d %b %Y %H:%M:%S GMT', expires.timetuple()) '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple())
if max_age: if max_age is not None:
http_cookie += '; Max-Age=' + str(max_age) http_cookie += '; Max-Age=' + str(max_age)
if secure: if secure:
http_cookie += '; Secure' http_cookie += '; Secure'
@@ -616,10 +616,10 @@ class Response:
:param cookie: The cookie's name. :param cookie: The cookie's name.
:param kwargs: Any cookie opens and flags supported by :param kwargs: Any cookie opens and flags supported by
``set_cookie()`` except ``expires``. ``set_cookie()`` except ``expires`` and ``max_age``.
""" """
self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT', self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT',
**kwargs) max_age=0, **kwargs)
def complete(self): def complete(self):
if isinstance(self.body, bytes) and \ if isinstance(self.body, bytes) and \
@@ -774,6 +774,9 @@ class Response:
first. first.
""" """
if content_type is None: if content_type is None:
if compressed and filename.endswith('.gz'):
ext = filename[:-3].split('.')[-1]
else:
ext = filename.split('.')[-1] ext = filename.split('.')[-1]
if ext in Response.types_map: if ext in Response.types_map:
content_type = Response.types_map[ext] content_type = Response.types_map[ext]
@@ -862,8 +865,6 @@ class URLPattern():
if arg is None: if arg is None:
return return
if 'name' in segment: if 'name' in segment:
if not arg:
return
args[segment['name']] = arg args[segment['name']] = arg
if path is not None: if path is not None:
return return
@@ -879,6 +880,8 @@ class URLPattern():
def _string_segment(self, value): def _string_segment(self, value):
s = value.split('/', 1) s = value.split('/', 1)
if len(s[0]) == 0:
return None, None
return s[0], s[1] if len(s) > 1 else None return s[0], s[1] if len(s) > 1 else None
def _int_segment(self, value): def _int_segment(self, value):
@@ -1191,7 +1194,7 @@ class Microdot:
Example:: Example::
import asyncio import asyncio
from microdot_asyncio import Microdot from microdot import Microdot
app = Microdot() app = Microdot()
@@ -1268,7 +1271,7 @@ class Microdot:
Example:: Example::
from microdot_asyncio import Microdot from microdot import Microdot
app = Microdot() app = Microdot()
@@ -1366,7 +1369,12 @@ class Microdot:
if res is None: if res is None:
res = await invoke_handler( res = await invoke_handler(
f, req, **req.url_args) f, req, **req.url_args)
if isinstance(res, int):
res = '', res
if isinstance(res, tuple): if isinstance(res, tuple):
if isinstance(res[0], int):
res = ('', res[0],
res[1] if len(res) > 1 else {})
body = res[0] body = res[0]
if isinstance(res[1], int): if isinstance(res[1], int):
status_code = res[1] status_code = res[1]

View File

@@ -1,7 +1,6 @@
import jwt import jwt
from microdot.microdot import invoke_handler from microdot.microdot import invoke_handler
from microdot.helpers import wraps
secret_key = None
class SessionDict(dict): class SessionDict(dict):
@@ -30,14 +29,21 @@ class Session:
""" """
secret_key = None secret_key = None
def __init__(self, app=None, secret_key=None): def __init__(self, app=None, secret_key=None, cookie_options=None):
self.secret_key = secret_key self.secret_key = secret_key
self.cookie_options = cookie_options or {}
if app is not None: if app is not None:
self.initialize(app) self.initialize(app)
def initialize(self, app, secret_key=None): def initialize(self, app, secret_key=None, cookie_options=None):
if secret_key is not None: if secret_key is not None:
self.secret_key = secret_key self.secret_key = secret_key
if cookie_options is not None:
self.cookie_options = cookie_options
if 'path' not in self.cookie_options:
self.cookie_options['path'] = '/'
if 'http_only' not in self.cookie_options:
self.cookie_options['http_only'] = True
app._session = self app._session = self
def get(self, request): def get(self, request):
@@ -57,13 +63,7 @@ class Session:
if session is None: if session is None:
request.g._session = SessionDict(request, {}) request.g._session = SessionDict(request, {})
return request.g._session return request.g._session
try: request.g._session = SessionDict(request, self.decode(session))
session = jwt.decode(session, self.secret_key,
algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
request.g._session = SessionDict(request, {})
else:
request.g._session = SessionDict(request, session)
return request.g._session return request.g._session
def update(self, request, session): def update(self, request, session):
@@ -89,12 +89,12 @@ class Session:
if not self.secret_key: if not self.secret_key:
raise ValueError('The session secret key is not configured') raise ValueError('The session secret key is not configured')
encoded_session = jwt.encode(session, self.secret_key, encoded_session = self.encode(session)
algorithm='HS256')
@request.after_request @request.after_request
def _update_session(request, response): def _update_session(request, response):
response.set_cookie('session', encoded_session, http_only=True) response.set_cookie('session', encoded_session,
**self.cookie_options)
return response return response
def delete(self, request): def delete(self, request):
@@ -117,10 +117,21 @@ class Session:
""" """
@request.after_request @request.after_request
def _delete_session(request, response): def _delete_session(request, response):
response.set_cookie('session', '', http_only=True, response.delete_cookie('session', **self.cookie_options)
expires='Thu, 01 Jan 1970 00:00:01 GMT')
return response return response
def encode(self, payload, secret_key=None):
return jwt.encode(payload, secret_key or self.secret_key,
algorithm='HS256')
def decode(self, session, secret_key=None):
try:
payload = jwt.decode(session, secret_key or self.secret_key,
algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
return {}
return payload
def with_session(f): def with_session(f):
"""Decorator that passes the user session to the route handler. """Decorator that passes the user session to the route handler.
@@ -136,13 +147,9 @@ def with_session(f):
Note that the decorator does not save the session. To update the session, Note that the decorator does not save the session. To update the session,
call the :func:`session.save() <microdot.session.SessionDict.save>` method. call the :func:`session.save() <microdot.session.SessionDict.save>` method.
""" """
@wraps(f)
async def wrapper(request, *args, **kwargs): async def wrapper(request, *args, **kwargs):
return await invoke_handler( return await invoke_handler(
f, request, request.app._session.get(request), *args, **kwargs) f, request, request.app._session.get(request), *args, **kwargs)
for attr in ['__name__', '__doc__', '__module__', '__qualname__']:
try:
setattr(wrapper, attr, getattr(f, attr))
except AttributeError: # pragma: no cover
pass
return wrapper return wrapper

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
from microdot.helpers import wraps
class SSE: class SSE:
@@ -12,7 +13,7 @@ class SSE:
self.event = asyncio.Event() self.event = asyncio.Event()
self.queue = [] self.queue = []
async def send(self, data, event=None): async def send(self, data, event=None, event_id=None):
"""Send an event to the client. """Send an event to the client.
:param data: the data to send. It can be given as a string, bytes, dict :param data: the data to send. It can be given as a string, bytes, dict
@@ -20,6 +21,8 @@ class SSE:
Any other types are converted to string before sending. Any other types are converted to string before sending.
:param event: an optional event name, to send along with the data. If :param event: an optional event name, to send along with the data. If
given, it must be a string. 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.
""" """
if isinstance(data, (dict, list)): if isinstance(data, (dict, list)):
data = json.dumps(data).encode() data = json.dumps(data).encode()
@@ -28,6 +31,8 @@ class SSE:
elif not isinstance(data, bytes): elif not isinstance(data, bytes):
data = str(data).encode() data = str(data).encode()
data = b'data: ' + data + b'\n\n' data = b'data: ' + data + b'\n\n'
if event_id:
data = b'id: ' + event_id.encode() + b'\n' + data
if event: if event:
data = b'event: ' + event.encode() + b'\n' + data data = b'event: ' + event.encode() + b'\n' + data
self.queue.append(data) self.queue.append(data)
@@ -99,6 +104,7 @@ def with_sse(f):
# send a named event # send a named event
await sse.send('hello', event='greeting') await sse.send('hello', event='greeting')
""" """
@wraps(f)
async def sse_handler(request, *args, **kwargs): async def sse_handler(request, *args, **kwargs):
return sse_response(request, f, *args, **kwargs) return sse_response(request, f, *args, **kwargs)

View File

@@ -77,7 +77,7 @@ class TestClient:
The following example shows how to create a test client for an application The following example shows how to create a test client for an application
and send a test request:: and send a test request::
from microdot_asyncio import Microdot from microdot import Microdot
app = Microdot() app = Microdot()
@@ -112,9 +112,13 @@ class TestClient:
headers['Host'] = 'example.com:1234' headers['Host'] = 'example.com:1234'
return body, headers return body, headers
def _process_cookies(self, headers): def _process_cookies(self, path, headers):
cookies = '' cookies = ''
for name, value in self.cookies.items(): for name, value in self.cookies.items():
if isinstance(value, tuple):
value, cookie_path = value
if not path.startswith(cookie_path):
continue
if cookies: if cookies:
cookies += '; ' cookies += '; '
cookies += name + '=' + value cookies += name + '=' + value
@@ -123,7 +127,7 @@ class TestClient:
headers['Cookie'] += '; ' + cookies headers['Cookie'] += '; ' + cookies
else: else:
headers['Cookie'] = cookies headers['Cookie'] = cookies
return cookies, headers return headers
def _render_request(self, method, path, headers, body): def _render_request(self, method, path, headers, body):
request_bytes = '{method} {path} HTTP/1.0\n'.format( request_bytes = '{method} {path} HTTP/1.0\n'.format(
@@ -139,26 +143,45 @@ class TestClient:
for cookie in cookies: for cookie in cookies:
cookie_name, cookie_value = cookie.split('=', 1) cookie_name, cookie_value = cookie.split('=', 1)
cookie_options = cookie_value.split(';') cookie_options = cookie_value.split(';')
path = '/'
delete = False delete = False
for option in cookie_options[1:]: for option in cookie_options[1:]:
if option.strip().lower().startswith('expires='): option = option.strip().lower()
_, e = option.strip().split('=', 1) if option.startswith(
'max-age='): # pragma: no cover
_, age = option.split('=', 1)
try:
age = int(age)
except ValueError: # pragma: no cover
age = 0
if age <= 0:
delete = True
elif option.startswith('expires='):
_, e = option.split('=', 1)
# this is a very limited parser for cookie expiry # this is a very limited parser for cookie expiry
# that only detects a cookie deletion request when # that only detects a cookie deletion request when
# the date is 1/1/1970 # the date is 1/1/1970
if '1 jan 1970' in e.lower(): # pragma: no branch if '1 jan 1970' in e.lower(): # pragma: no branch
delete = True delete = True
break elif option.startswith('path='):
_, path = option.split('=', 1)
if delete: if delete:
if cookie_name in self.cookies: # pragma: no branch if cookie_name in self.cookies: # pragma: no branch
cookie_path = self.cookies[cookie_name][1] \
if isinstance(self.cookies[cookie_name], tuple) \
else '/'
if path == cookie_path:
del self.cookies[cookie_name] del self.cookies[cookie_name]
else: else:
if path == '/':
self.cookies[cookie_name] = cookie_options[0] self.cookies[cookie_name] = cookie_options[0]
else:
self.cookies[cookie_name] = (cookie_options[0], path)
async def request(self, method, path, headers=None, body=None, sock=None): async def request(self, method, path, headers=None, body=None, sock=None):
headers = headers or {} headers = headers or {}
body, headers = self._process_body(body, headers) body, headers = self._process_body(body, headers)
cookies, headers = self._process_cookies(headers) headers = self._process_cookies(path, headers)
request_bytes = self._render_request(method, path, headers, body) request_bytes = self._render_request(method, path, headers, body)
if sock: if sock:
reader = sock[0] reader = sock[0]

View File

@@ -2,6 +2,7 @@ import binascii
import hashlib import hashlib
from microdot import Request, Response from microdot import Request, Response
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
from microdot.helpers import wraps
class WebSocketError(Exception): class WebSocketError(Exception):
@@ -192,6 +193,7 @@ async def websocket_upgrade(request):
def websocket_wrapper(f, upgrade_function): def websocket_wrapper(f, upgrade_function):
@wraps(f)
async def wrapper(request, *args, **kwargs): async def wrapper(request, *args, **kwargs):
ws = await upgrade_function(request) ws = await upgrade_function(request)
try: try:

1
tests/files/test.txt.gz Normal file
View File

@@ -0,0 +1 @@
foo

View File

@@ -203,6 +203,7 @@ class TestMicrodot(unittest.TestCase):
req.cookies['one'] + req.cookies['two'] + req.cookies['three']) req.cookies['one'] + req.cookies['two'] + req.cookies['three'])
res.set_cookie('four', '4') res.set_cookie('four', '4')
res.delete_cookie('two', path='/') res.delete_cookie('two', path='/')
res.delete_cookie('one', path='/bad')
return res return res
client = TestClient(app, cookies={'one': '1', 'two': '2'}) client = TestClient(app, cookies={'one': '1', 'two': '2'})
@@ -273,6 +274,14 @@ class TestMicrodot(unittest.TestCase):
return '<p>four</p>', 202, \ return '<p>four</p>', 202, \
{'Content-Type': 'text/html; charset=UTF-8'} {'Content-Type': 'text/html; charset=UTF-8'}
@app.route('/status')
def five(req):
return 202
@app.route('/status-headers')
def six(req):
return 202, {'Content-Type': 'text/html; charset=UTF-8'}
client = TestClient(app) client = TestClient(app)
res = self._run(client.get('/body')) res = self._run(client.get('/body'))
@@ -298,6 +307,18 @@ class TestMicrodot(unittest.TestCase):
'text/html; charset=UTF-8') 'text/html; charset=UTF-8')
self.assertEqual(res.text, '<p>four</p>') self.assertEqual(res.text, '<p>four</p>')
res = self._run(client.get('/status'))
self.assertEqual(res.text, '')
self.assertEqual(res.status_code, 202)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
res = self._run(client.get('/status-headers'))
self.assertEqual(res.text, '')
self.assertEqual(res.status_code, 202)
self.assertEqual(res.headers['Content-Type'],
'text/html; charset=UTF-8')
def test_before_after_request(self): def test_before_after_request(self):
app = Microdot() app = Microdot()

View File

@@ -136,10 +136,10 @@ class TestResponse(unittest.TestCase):
self.assertTrue(fd.response.endswith(b'\r\n\r\nfoobar')) self.assertTrue(fd.response.endswith(b'\r\n\r\nfoobar'))
def test_create_from_other(self): def test_create_from_other(self):
res = Response(123) res = Response(23.7)
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers, {}) self.assertEqual(res.headers, {})
self.assertEqual(res.body, 123) self.assertEqual(res.body, 23.7)
def test_create_with_status_code(self): def test_create_with_status_code(self):
res = Response('not found', 404) res = Response('not found', 404)
@@ -193,6 +193,7 @@ class TestResponse(unittest.TestCase):
expires='Tue, 05 Nov 2019 02:23:54 GMT', max_age=123, expires='Tue, 05 Nov 2019 02:23:54 GMT', max_age=123,
secure=True, http_only=True) secure=True, http_only=True)
res.delete_cookie('foo8', http_only=True) res.delete_cookie('foo8', http_only=True)
res.delete_cookie('foo9', path='/s')
self.assertEqual(res.headers, {'Set-Cookie': [ self.assertEqual(res.headers, {'Set-Cookie': [
'foo1=bar1', 'foo1=bar1',
'foo2=bar2; Path=/; Partitioned', 'foo2=bar2; Path=/; Partitioned',
@@ -203,7 +204,10 @@ class TestResponse(unittest.TestCase):
'foo7=bar7; Path=/foo; Domain=example.com:1234; ' 'foo7=bar7; Path=/foo; Domain=example.com:1234; '
'Expires=Tue, 05 Nov 2019 02:23:54 GMT; Max-Age=123; Secure; ' 'Expires=Tue, 05 Nov 2019 02:23:54 GMT; Max-Age=123; Secure; '
'HttpOnly', 'HttpOnly',
'foo8=; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly', ('foo8=; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; '
'HttpOnly'),
('foo9=; Path=/s; Expires=Thu, 01 Jan 1970 00:00:01 GMT; '
'Max-Age=0'),
]}) ]})
def test_redirect(self): def test_redirect(self):
@@ -276,6 +280,17 @@ class TestResponse(unittest.TestCase):
'application/octet-stream') 'application/octet-stream')
self.assertEqual(res.headers['Content-Encoding'], 'gzip') self.assertEqual(res.headers['Content-Encoding'], 'gzip')
def test_send_file_gzip_handling(self):
res = Response.send_file('tests/files/test.txt.gz')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'application/octet-stream')
res = Response.send_file('tests/files/test.txt.gz', compressed=True)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], 'text/plain')
self.assertEqual(res.headers['Content-Encoding'], 'gzip')
def test_default_content_type(self): def test_default_content_type(self):
original_content_type = Response.default_content_type original_content_type = Response.default_content_type
res = Response('foo') res = Response('foo')

View File

@@ -82,3 +82,77 @@ class TestSession(unittest.TestCase):
res = self._run(client.get('/')) res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
def test_session_default_path(self):
app = Microdot()
Session(app, secret_key='some-other-secret')
client = TestClient(app)
@app.get('/')
@with_session
def index(req, session):
session['foo'] = 'bar'
session.save()
return ''
@app.get('/child')
@with_session
def child(req, session):
return str(session.get('foo'))
@app.get('/delete')
@with_session
def delete(req, session):
session.delete()
return ''
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
res = self._run(client.get('/child'))
self.assertEqual(res.text, 'bar')
res = self._run(client.get('/delete'))
res = self._run(client.get('/child'))
self.assertEqual(res.text, 'None')
def test_session_custom_path(self):
app = Microdot()
session_ext = Session()
session_ext.initialize(app, secret_key='some-other-secret',
cookie_options={'path': '/child',
'http_only': False})
client = TestClient(app)
@app.get('/')
@with_session
def index(req, session):
return str(session.get('foo'))
@app.get('/child')
@with_session
def child(req, session):
session['foo'] = 'bar'
session.save()
return ''
@app.get('/child/foo')
@with_session
def foo(req, session):
return str(session.get('foo'))
@app.get('/child/delete')
@with_session
def delete(req, session):
session.delete()
return ''
res = self._run(client.get('/child'))
self.assertEqual(res.status_code, 200)
res = self._run(client.get('/'))
self.assertEqual(res.text, 'None')
res = self._run(client.get('/child/foo'))
self.assertEqual(res.text, 'bar')
res = self._run(client.get('/child/delete'))
res = self._run(client.get('/'))
self.assertEqual(res.text, 'None')
res = self._run(client.get('/child/foo'))
self.assertEqual(res.text, 'None')

View File

@@ -23,6 +23,8 @@ class TestWebSocket(unittest.TestCase):
async def handle_sse(request, sse): async def handle_sse(request, sse):
await sse.send('foo') await sse.send('foo')
await sse.send('bar', event='test') await sse.send('bar', event='test')
await sse.send('bar', event='test', event_id='id42')
await sse.send('bar', event_id='id42')
await sse.send({'foo': 'bar'}) await sse.send({'foo': 'bar'})
await sse.send([42, 'foo', 'bar']) await sse.send([42, 'foo', 'bar'])
await sse.send(ValueError('foo')) await sse.send(ValueError('foo'))
@@ -34,6 +36,8 @@ class TestWebSocket(unittest.TestCase):
self.assertEqual(response.headers['Content-Type'], 'text/event-stream') self.assertEqual(response.headers['Content-Type'], 'text/event-stream')
self.assertEqual(response.text, ('data: foo\n\n' self.assertEqual(response.text, ('data: foo\n\n'
'event: test\ndata: bar\n\n' 'event: test\ndata: bar\n\n'
'event: test\nid: id42\ndata: bar\n\n'
'id: id42\ndata: bar\n\n'
'data: {"foo": "bar"}\n\n' 'data: {"foo": "bar"}\n\n'
'data: [42, "foo", "bar"]\n\n' 'data: [42, "foo", "bar"]\n\n'
'data: foo\n\n' 'data: foo\n\n'

View File

@@ -26,10 +26,14 @@ class TestURLPattern(unittest.TestCase):
p = URLPattern('/<arg>') p = URLPattern('/<arg>')
self.assertEqual(p.match('/foo'), {'arg': 'foo'}) self.assertEqual(p.match('/foo'), {'arg': 'foo'})
self.assertIsNone(p.match('/')) self.assertIsNone(p.match('/'))
self.assertIsNone(p.match('//'))
self.assertIsNone(p.match('')) self.assertIsNone(p.match(''))
self.assertIsNone(p.match('foo/')) self.assertIsNone(p.match('foo/'))
self.assertIsNone(p.match('/foo/')) self.assertIsNone(p.match('/foo/'))
self.assertIsNone(p.match('//foo/'))
self.assertIsNone(p.match('/foo//'))
self.assertIsNone(p.match('/foo/bar')) self.assertIsNone(p.match('/foo/bar'))
self.assertIsNone(p.match('/foo//bar'))
p = URLPattern('/<arg>/') p = URLPattern('/<arg>/')
self.assertEqual(p.match('/foo/'), {'arg': 'foo'}) self.assertEqual(p.match('/foo/'), {'arg': 'foo'})
@@ -64,6 +68,8 @@ class TestURLPattern(unittest.TestCase):
def test_int_argument(self): def test_int_argument(self):
p = URLPattern('/users/<int:id>') p = URLPattern('/users/<int:id>')
self.assertEqual(p.match('/users/123'), {'id': 123}) self.assertEqual(p.match('/users/123'), {'id': 123})
self.assertEqual(p.match('/users/-123'), {'id': -123})
self.assertEqual(p.match('/users/0'), {'id': 0})
self.assertIsNone(p.match('/users/')) self.assertIsNone(p.match('/users/'))
self.assertIsNone(p.match('/users/abc')) self.assertIsNone(p.match('/users/abc'))
self.assertIsNone(p.match('/users/123abc')) self.assertIsNone(p.match('/users/123abc'))